An account and database management framework, completing Nasse
Manipulate your databases as if you never left Python
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system.
You will need Python 3 to use this module
# vermin output
Minimum required versions: 3.8
Incompatible versions: 2
According to Vermin (--backport typing
), Python 3.8 is needed for typing.Literal
I haven't checked the minimum requirements for the MongoDB features used in this library yet but you should always use the latest versions for maximum security.
Also this framework has been tested on MongoDB v4.4.5
(locally), MongoDB v4.4.12
(on Atlas) and the latest version of MongoDB on the CI tests.
Always check if your Python version works with yuno
before using it in production.
pip install --upgrade yuno
pip install --upgrade git+https://github.com/Animenosekai/yuno
You can check if you successfully installed it by printing out its version:
$ python -c "import yuno; print(yuno.__version__)"
# output:
yuno v1.0
This framework aims to bring effortless database management and database schemas in Python, in order to make everything predictable and easy to debug.
You will never need to learn and search for obscure MongoDB features again because everything is made to behave like normal Python objects.
If you have a MongoDB database already running (for example: using MongoDB Atlas, or on a separate server), you can skip this part.
To start a process you will need to create a new MongoDB
object with the configurations (refer to Configuration) and start it from there.
Example
from yuno import MongoDB
server = MongoDB()
server.start() # this will launch MongoDB and wait for it to run
...
server.kill() # this will kill the MongoDB process
The start
method can get extra parameters that will modify the way MongoDB is launched:
executable
: The path to the MongoDB executable.wait
: When you have setfork
toFalse
, this is the number of seconds to wait for the process to start. (I was planning to use the output from the process to determine when it is listening for connections butmongod
doesn't seem to "flush" its output so it was just hanging waiting for new output to come until the process was killed)keep_alive
: Whether to keep the process alive or not (fork will be enabled) after Python quits (it will not registerself.kill
at cleanup).
MongoDB configuration is made easy with Yuno!
You can just pass configuration values into MongoDB
to configure it!
You can configure a lot of important values natively for now and you can add more obscure ones when launching the process.
host
: The host of the MongoDB server (net.bindIp)port
: The port of the MongoDB server (net.port)db_path
: The path of the MongoDB database (storage.dbPath)fork
: Whether to fork the MongoDB process or not (processManagement.fork)log_config
: The MongoDB logging configuration (systemLog)max_connections
: The maximum number of connections allowed (net.maxIncomingConnections)json_validation
: Whether to enable the JSON validation or not (net.wireObjectCheck)ipv6
: Whether to enable IPv6 or not (net.ipv6)monitoring
: Whether to enable the free MongoDB monitoring or not (cloud.monitoring.free.state)
The content of the log_config
parameter should be a LogConfig
object (or a dictionary containing data to instantiate a LogConfig
class).
This parameter is a class on its own because of its complexity.
Parameters
verbosity
: The verbosity level of the MongoDB logging systempath
: The path of the MongoDB log file. It can also be TERMINAL or SYSLOG (the systemLog.destination value will be set automatically according to this value)append
: Whether to append to the log file or nottimezone
: The timezone of the timestamps formatdebug
: Whether to enable the debug mode or not (this will enable max verbosity and--traceExceptions
)
Example
from yuno import MongoDB, LogConfig
from yuno.launcher import Timezone
server = MongoDB(db_path="./db/test", fork=True, monitoring=False, log_config=LogConfig(verbosity=2, path="./logs/db/test.log", timezone=Timezone.UTC))
server.start()
You can also load the config from a file or dump it into a file using the built in methods (available on LogConfig
and MongoDB
):
to_cli_args
will dump the configuration to a list of CLI arguments to give to theMongoDB
(mongod
) processloads
will load the configuration from a YAML string or a dictionary of valuesload
will load the configuration from a YAML filedumps
will dump the configuration to a YAML stringdump
will dump the configuration to a YAML fileto_dict
(ordict(configuration_object)
) will dump the configuration to a dictionary (you have acamelCase
parameter to make the keys camelCased)
This is especially useful when you already have a configuration file or want to share the configuration to another user not using Yuno.
Example
from yuno import MongoDB
server = MongoDB() # will load all of the default configurations
server.load("./db/mongo.conf") # will reinitialize the object with the config file, keeping the default values for values that aren't specified
server.dump("./db/mongo_new.conf") # will dump the configurations to a new file (to share or use later)
# will show the configuration
print("MongoDB configuration")
print("-" * 10)
for key, val in server.to_dict(camelCase=True):
print(key, "=", val)
Wether you use the built-in MongoDB
object to launch a MongoDB process or just host it on the cloud/on another server, you will need to connect to it using YunoClient
.
Here are the parameters used to connect to a server:
host
: The host or list of hostnames to connect to. You can usehost
to pass in a URI string or a MongoDB object (in which case you won't need to useport
).port
: The port to connect to.tz_aware
: Whether to use timezone aware datetimes or not.connect
: Whether to connect before making any operation to the server or not.kwargs
: Options to pass to the PyMongo client. (you don't need to setkwargs
manually, just pass the parameters as normal parameters)
Example
# Using the "MongoDB" object
from yuno import MongoDB
from yuno import YunoClient
server = MongoDB()
server.start()
client = YunoClient(server) # you are connecting to the server here
To access databases from the client, all you need to do is access its name as an attribute or an item:
client.database_name
# or
client["database_name"]
In both case, this will return the same YunoDatabase object.
The client["database_name"]
syntax is especially useful if you use a database with the name of a method overwritten by YunoClient (for example watch
, server_info
, address
, close
, etc.).
You can find a list of overwritten attributes under the __overwritten__
attribute.
YunoClient can be used to establish the database schema.
This basically means that you can already type hint databases by creating your own clients.
from yuno import YunoClient
from yuno import YunoDatabase
class MyClient(YunoClient):
production: YunoDatabase # you can here use your custom databases (refer to the "Using databases" section)
scores: YunoDatabase
client = MyClient("mongodb+srv://anise:password@yunotest.host.mongodb.net/production")
client.production # will return the `production` database
# YunoDatabase('production')
This helps with establishing a schema and helps your code editor guide you when writing code, resulting in less time searching for types, available databases and looking back at your code.
It has serveral other methods, which are picked and adapted from the original PyMongo MongoClient
.
Examples
close
is used to close the connection to the serverdatabase_names
is used to retrieve the list of databases created.server_info
will return some info about the current MongoDB server in a special object calledBuildInfo
(yuno.client.BuildInfo
).watch
returns a cursor to watch the cluster for changes and events.on
will register a callback function which will be called when the specified operation/event is performed on the server.
You can also use pythonic syntax to make some operations:
- Dropping a database
del client.test
# or
del client["test"]
# will drop (delete) the "test" database
A "database" is what holds collections in MongoDB.
To access collections from the client, all you need to do is access its name as an attribute or an item:
database.collection_name
# or
database["collection_name"]
In both case, this will return the same YunoCollection object.
The database["collection_name"]
syntax is especially useful if you use a database with the name of a method overwritten by YunoDatabase (for example watch
, on
, command
, aggregate
, etc.).
You can find a list of overwritten attributes under the __overwritten__
attribute.
YunoCollection
is the class returned by YunoDatabase
when querying for one.
# using the client variable defined before
accounts = production_database.accounts
# this is YunoCollection object
You can define your own databases to type hint them (for the same reasons as before)
from yuno import YunoClient, YunoDatabase, YunoCollection
class MyDatabase(YunoDatabase):
accounts: YunoCollection # you will also be able to create your own collections
class MyClient(YunoClient):
production: MyDatabase
client = MyClient("mongodb+srv://anise:password@yunotest.host.mongodb.net/production")
production_database = client.production # will return the custom MyDatabase object and code editors will help you write code
# MyDatabase('production')
production_database.accounts # this is a YunoCollection object
It has serveral other methods, which are picked and adapted from the original PyMongo MongoClient
.
Examples
aggregate
is used to perform an aggregation on the databasecreate_collection
is used to create a new collectionlist_collection_names
returns a list of collections availablewatch
returns a cursor to watch the database for changes and events.on
will register a callback function which will be called when the specified operation/event is performed on the database.
You can also use pythonic syntax to make some operations:
- Dropping a collection
del database.account
# or
del database["account"]
# will drop (delete) the "account" collection
A "collection" is what holds documents in MongoDB.
To access documents from the collection, all you need to do is access its name as an attribute or an item:
collection.document_id
# or
collection["document_id"]
In both case, this will return the same YunoCollection object.
The collection["document_id"]
syntax is especially useful if you use a document with the _id of a method overwritten by YunoCollection (for example watch
, on
, find
, aggregate
, etc.).
You can find a list of overwritten attributes under the __overwritten__
attribute.
YunoCollection
is the class returned by YunoDatabase
when querying for one.
# using the client variable defined before
accounts = production_database.accounts
# this is YunoCollection object
You can define your own collections to type hint them (for the same reasons as before)
from bson import ObjectId
from yuno import YunoClient, YunoDatabase, YunoCollection, YunoDict
class MyCollection(YunoCollection):
special_document: YunoDict # you will also be able to create your own documents
class MyDatabase(YunoDatabase):
accounts: MyCollection
class MyClient(YunoClient):
production: MyDatabase
client = MyClient("mongodb+srv://anise:password@yunotest.host.mongodb.net/production")
production_database = client.production
accounts = production_database.accounts
accounts.special_document # this is a YunoDict object
There is a special __type__
attribute which is used to define the default type of the documents in the collection.
This is especially useful if all of the documents in the collection share the same schema.
You can use type hints to define the schema of special documents (a document which gives global information about the collection for example).
It has serveral other methods, which are picked and adapted from the original PyMongo MongoClient
.
Examples
count
is used to count the number of documents matching the given filterfind
is used to find documents matching the given filterindex
creates a new index on the collectionaggregate
returns an aggregation of documents following the given pipelinewatch
returns a cursor to watch the database for changes and events.on
will register a callback function which will be called when the specified operation/event is performed on the database.
You can also use pythonic syntax to make some operations:
- Deleting a document
del collection.special_document
# or
del collection["special_document"]
# will delete the "special_document" document
- Set a document (create or replace)
collection.special_document = {"_id": "special_document", "name": "Special document"}
# or
collection["special_document"] = {"_id": "special_document", "name": "Special document"}
# will replace the "special_document" document with the one defined above
- Verify if the given document exists
"special_document" in collection
# will return True if the document (with _id == "special_document") exists
An "object" is what represents any object in a document, or even the document itself.
To access objects from another object (the highest hierarchy parent object being the document), all you need to do is access its name as an attribute or an item:
document.object_name
# or
document["object_name"]
In both case, this will return the same YunoObject object.
The document["object_name"]
syntax is especially useful if you use an object with its name being of a method overwritten by YunoObject (for example watch
, on
, reload
, delete
, etc.).
You can find a list of overwritten attributes under the __overwritten__
attribute.
YunoObject
is the class returned by YunoCollection
when querying for one.
# using the client variable defined before
document = accounts.special_document
# this is YunoObject object
You can define your own objects to type hint them (for the same reasons as before)
from yuno import YunoClient, YunoDatabase, YunoCollection, YunoDict
class CustomObject(YunoDict):
hello: str = "world"
do_not_exist: str = "this does not exist" # its default value if not found in db
class CustomDocument(YunoDict):
__lazy__ = ["hello"] # lazy loaded attribute
hello: str
world: str = "heyhey"
a: CustomObject # nested object ^^
class SpecialDocument(YunoDict):
__lazy__ = ["specialAttributes"]
specialAttributes: list[str]
version: str
class CustomCollection(YunoCollection):
__type__ = CustomDocument # the default type of document in the collection
special: SpecialDocument # a special document type
class CustomDatabase(YunoDatabase):
__yuno_test__: CustomCollection
class CustomClient(YunoClient):
test_database: CustomDatabase
client = CustomClient("mongodb+srv://anise:password@yunotest.host.mongodb.net/production")
test_database = client.test_database
test_collection = test_database.__yuno_test__
special_doc = test_collection.special
special_doc # this is a SpecialDocument object
document = test_collection.any_document
document # this is a CustomDocument object
document.hello # this is a str object, but lazy loaded (not loaded until needed)
document.a # this is CustomObject object
document.a.hello # this is a str
document.a.do_not_exist # this is a str (and if it's not found in the db, it will be the value given by default "this does not exist")
There is a special __lazy__
attribute which is used to define attributes which won't be loaded until needed.
This is especially useful for attributes which are expensive to load or not needed in normal circumstances.
You can use type hints to define the schema of some attributes.
Objects acts as regular python objects.
For example, a YunoDict object can be used as a regular python dict:
object # this is a YunoDict object
object.get("key") # this is a str
object.pop("key") # remove the key from the object
object.items() # this is a list of tuples
object.keys() # this is a list of str
...
for key, value in object.items():
print(key, value)
del object["key"]
and a YunoList object can be used as a regular python list:
object # this is a YunoList object
object.append("value") # add a value to the list
object.pop() # remove the last value from the list
object.extend(["value1", "value2"]) # add multiple values to the list
object.index("value") # return the index of the value in the list
object.insert(0, "value") # insert a value at the beginning of the list
object.remove("value") # remove the first value from the list
object.reverse() # reverse the list
object.sort() # sort the list
...
for value in object:
print(value)
Some methods don't come from regular Python data types, but are specific to YunoObjects:
Examples
delete
deletes the current object from the databasereload
reloads the current object from the database (replaces the current object with the one from the database)watch
returns a cursor to watch the database for changes and events.on
will register a callback function which will be called when the specified operation/event is performed on the object.
Instead of the reload
method, you also have a __realtime__
attribute which you can set to True
if you want the object to follow the updates on the database (you will have an object which is always up to date).
document # this is a YunoObject (YunoDict, YunoList, etc.) object
document.__realtime__ = True
# this will make the object follow the updates on the database
class CustomObject(YunoDict):
__realtime__ = True
hello: str = "world"
class CustomCollection(YunoCollection):
__type__ = CustomObject
# any object coming from the CustomCollection collection will be a CustomObject object and will be a "realtime" object, following the updates on the database
You can also use pythonic syntax to make some operations:
- Deleting a document
del object.key
# or
del object["key"]
# will delete the "key" attribute from the object
- Set an attribute (create or replace)
object.key = {"hello": "world"}
# or
object["key"] = {"hello": "world"}
# will replace the "key" attribute with the one defined above
- Verify if the given document exists
"key" in object
# will return True if the attribute exists
Yuno being a framework, it also provides a set of tools to help you secure your data and logins for your users.
They are all located under yuno.security
.
Encrypt is a module to encrypt your data using AES.
The key can be managed by the database and the token is made recognizable by the given prefix.
>>> from yuno.security.encrypt import AES
>>> encryption = AES(<your key>)
>>> encryption.encrypt("hello world")
'yuno+312e302e30,1f1d5944d37904e6fe0d129efc11207c,cbb99af1cfb54e08b31d4e,f4876580de57a521791bc0cc527c85cc'
You can also use your database to automatically create and store the key.
>>> client = YunoClient(...)
>>> encryption = AES(key=client)
# the key will be randomly generated and stored in the database to be retrieved automatically when needed
>>> encryption.encrypt("hello world")
'yuno+312e302e30,1f1d5944d37904e6fe0d129efc11207c,cbb99af1cfb54e08b31d4e,f4876580de57a521791bc0cc527c85cc'
>>> encryption.decrypt("yuno+312e302e30,1f1d5944d37904e6fe0d129efc11207c,cbb99af1cfb54e08b31d4e,f4876580de57a521791bc0cc527c85cc")
'hello world'
You can also hash data and passwords using Yuno
This is all located inside yuno.security.hash
.
The data hasher will use SHA-256 to hash your data.
Create a Hasher
instance.
Four methods are available:
hash
: Which will automatically detect which method to use.hash_string
: To hash a stringhash_bytes
: To hash a bytes objecthash_buffer
: To hash an IO buffer (file for example).
Yuno uses Argon2id, one of the strongest password hashing algorithms, to hash passwords.
You can use it by creating an instance of PasswordHasher
.
When initializing the PasswordHasher, you will need to pass a pepper
, a kind of system key, to alter the given passwords and make them unique to the server.
The pepper
parameter can also be a database object, in which case a random key will be generated and stored inside the database.
>>> from yuno.security.hash import PasswordHasher
>>> hasher = PasswordHasher(pepper="my_pepper")
>>> hasher.hash("my_password")
'$argon2id$v=19$m=102400,t=2,p=8$YKj47J7lty47hXrsgIwq0A$XWtTO+CtuSvEWrVph9ZrNQ'
The hashed password follows the following schema:
PEPPER + PASSWORD + SALT(*)
(*) If provided when hashing, it's a per-password bias to make the password unique to an account for example.
hasher.hash("my_password", salt="my_salt")
the SALT can be for example an account ID
Two methods are provided to check if the given password is correct:
>>> hasher.verify(password="my_password", hashed="$argon2id$v=19$m=102400,t=2,p=8$YKj47J7lty47hXrsgIwq0A$XWtTO+CtuSvEWrVph9ZrNQ", salt="my_salt")
'$argon2id$v=19$m=102400,t=2,p=8$YKj47J7lty47hXrsgIwq0A$XWtTO+CtuSvEWrVph9ZrNQ'
This function checks if the given password matches the given hashed password and returns the hashed password if it matches.
This returned password can also be slightly different from the original hash because it will sometimes rehash it so make sure to update the hash password with the new one in the database.
>>> hasher.is_equal(password="my_password", hashed="$argon2id$v=19$m=102400,t=2,p=8$YKj47J7lty47hXrsgIwq0A$XWtTO+CtuSvEWrVph9ZrNQ", salt="my_salt")
True
Does basically the same thing as before but will do no verification, rehashing, whatsoever and will return True
or False
instead of raising an exception if the password don't match.
Finally, Yuno can also manage JWT Tokens.
A JWT is basically a JSON object encoded which is base64 encoded and signed with a secret key.
This way, anyone can read its content but nobody can modify it without the key.
This is why Yuno also gives you the option to pass a yuno.security.encrypt.AES
instance to encrypt the tokens with AES and make them unreadable to anyone without the key.
When initializing the TokenManager
, you will need to pass a key
which is the secret key used to sign the tokens.
This can also be a database object, in which case the key will be generated and stored inside the database.
You can also provide a sign
parameter, which will further sign the token from within the its content by adding to each token a random signature.
>>> from yuno.security.token import TokenManager
>>> token_manager = TokenManager(key="my_secret_key")
>>> token_manager.generate(user="id-123", username="username-123", roles=["admin", "user"]) # and any extra data
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NDYwNDEwNDcsImV4cCI6MTY0NjEyNzQ0NywidXNlciI6ImlkLTEyMyIsInVzZXJuYW1lIjoidXNlcm5hbWUtMTIzIiwicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19.2ddAuZU7ozW9_F2dz9JKZbxdsNZlFbkjm5WYiuJVuUo'
with encryption
>>> from yuno.security.encrypt import AES
>>> aes = AES()
>>> token_manager.generate(user="id-123", encryption=aes)
'yuno+312e302e30,667dfc91d461177e673582969809d4a8,fdf3e9a34532ca63389a15bcae8d7c0373221d12e617b56a9136b1d9288f1f2da3020390c5657451357ab73607fc2040a87f4194b1abd84e8b6e9767e2ffc04ae5f70e2869c60138f8a0922f2e2f9b31f6e0913bb6b2b3e7e7b6679e55c865a4a9598016fc713e6eb48353cc61bc9954c897794ff4f29f50e0d2282a930e26b7ee7bb547428bbedef74e88a4f827eeab5564c883b7,82beee1f51fc1c1de408cc4a49c15f13'
>>> token_manager.decode("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NDYwNDEwNDcsImV4cCI6MTY0NjEyNzQ0NywidXNlciI6ImlkLTEyMyIsInVzZXJuYW1lIjoidXNlcm5hbWUtMTIzIiwicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19.2ddAuZU7ozW9_F2dz9JKZbxdsNZlFbkjm5WYiuJVuUo")
{'iat': 1646037825, 'exp': 1646124194, 'user': 'id-123', 'username': 'username-123', 'roles': ['admin', 'user']}
with encryption
>>> token_manager.decode("yuno+312e302e30,667dfc91d461177e673582969809d4a8,fdf3e9a34532ca63389a15bcae8d7c0373221d12e617b56a9136b1d9288f1f2da3020390c5657451357ab73607fc2040a87f4194b1abd84e8b6e9767e2ffc04ae5f70e2869c60138f8a0922f2e2f9b31f6e0913bb6b2b3e7e7b6679e55c865a4a9598016fc713e6eb48353cc61bc9954c897794ff4f29f50e0d2282a930e26b7ee7bb547428bbedef74e88a4f827eeab5564c883b7,82beee1f51fc1c1de408cc4a49c15f13")
{'iat': 1646041175, 'exp': 1646127575, 'user': 'id-123'}
When using the
sign
parameter, an extra signature will be added to the token.
>>> token_manager = TokenManager("my_key", sign="my_sign")
>>> token_manager.generate(sub="id-123", username="username-123", roles=["admin", "user"])
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NDYwNDMwOTgsImV4cCI6MTY0NjEyOTQ5OCwidXNlciI6ImlkLTEyMyIsInJhbmQiOiI1N2IyZmVhODNlNjE1M2ZjIiwic2lnbiI6Ijg5MjQyZTdlOWNhMzAwMGU4M2NiZWUxMTQxZWQ4MTFhNGQ3M2NkNTliYTdkODE5ZjVkOTg2YzEzYzg5OGNjMzIiLCJ1c2VybmFtZSI6InVzZXJuYW1lLTEyMyIsInJvbGVzIjpbImFkbWluIiwidXNlciJdfQ.t0bL6_cqpZOeY2oJoBURSMhvagjRT3b_KlCBTFjFduI'
>>> token_manager.decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NDYwNDMwOTgsImV4cCI6MTY0NjEyOTQ5OCwidXNlciI6ImlkLTEyMyIsInJhbmQiOiI1N2IyZmVhODNlNjE1M2ZjIiwic2lnbiI6Ijg5MjQyZTdlOWNhMzAwMGU4M2NiZWUxMTQxZWQ4MTFhNGQ3M2NkNTliYTdkODE5ZjVkOTg2YzEzYzg5OGNjMzIiLCJ1c2VybmFtZSI6InVzZXJuYW1lLTEyMyIsInJvbGVzIjpbImFkbWluIiwidXNlciJdfQ.t0bL6_cqpZOeY2oJoBURSMhvagjRT3b_KlCBTFjFduI')
{'iat': 1646043098, 'exp': 1646129498, 'sub': 'id-123', 'rand': '57b2fea83e6153fc', 'sign': '89242e7e9ca3000e83cbee1141ed811a4d73cd59ba7d819f5d986c13c898cc32', 'username': 'username-123', 'roles': ['admin', 'user']}
Notice that a rand
and sign
fields are added to the token.
The rand
field is random data as hex generated to be used in the sign
field.
The sign
field is the signature of the token.
It is a SHA-256 hashed concatenation of the rand
field and the sign
provided to the token manager.
The goal here is to increase the number of keys and biases to make cracking the tokens and hashes way harder to any attacker.
As you can see, the JWT has the most layers because it is exposed to the public.
You should protect your databases and APIs and try to use some key rotation mechanism for maximum security.
Yuno works on top of PyMongo to make all of the operations to MongoDB.
It also uses PyJWT for the JWT encoding and decoding, Argon2 for the hashing and encryption of the passwords, and PyCryptodome for the AES encryption.
This module is currently in development and might contain bugs.
Feel free to use it in production if you feel like it is suitable for your production even if you may encounter issues.
Pull requests are welcome. For major changes, please open a discussion first to discuss what you would like to change.
Please make sure to update the tests as appropriate.
- PyMongo - To connect to MongoDB databases and make operations
- psutil - For cross-platform process management
- PyYAML - To parse YAML files (MongoDB configuration files)
- argon2-cffi - To hash and encrypt passwords with Argon2id
- PyJWT - To encode and decode JWT tokens
- PyCryptodome - To AES encrypt and decrypt data
- Anime no Sekai - Initial work - Animenosekai
Thank you Gratus
for your drawing of 'Yuno Gasai'
Personal work, CC BY-SA 4.0, https://commons.wikimedia.org/w/index.php?curid=64851976
This project is licensed under the MIT License - see the LICENSE file for details.