kopia lustrzana https://github.com/Yakifo/amqtt
removing superfluous dependency, updating readthedocs build process, plugin doc cleanup
rodzic
8e47ede192
commit
bfd2d86395
|
@ -1,15 +1,22 @@
|
|||
version: 2
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
tools:
|
||||
python: "3.13"
|
||||
jobs:
|
||||
pre_install:
|
||||
- pip install --upgrade pip
|
||||
- pip install uv
|
||||
- uv pip install --group dev --group docs
|
||||
- uv run pytest
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
tools:
|
||||
python: "3.13"
|
||||
apt_packages:
|
||||
- libldap2-dev
|
||||
- libsasl2-dev
|
||||
jobs:
|
||||
pre_install:
|
||||
- pip install --upgrade pip
|
||||
- pip install uv
|
||||
- uv venv
|
||||
- uv pip install --group dev --group docs ".[contrib]"
|
||||
- uv run pytest --mock-docker=true
|
||||
build:
|
||||
html:
|
||||
- uv run python -m mkdocs build --clean --site-dir $READTHEDOCS_OUTPUT/html --config-file mkdocs.rtd.yml
|
||||
|
||||
mkdocs:
|
||||
configuration: mkdocs.rtd.yml
|
||||
mkdocs:
|
||||
configuration: mkdocs.rtd.yml
|
||||
|
|
|
@ -27,7 +27,7 @@ from amqtt.session import Session
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CertificateAuthPlugin(BaseAuthPlugin):
|
||||
class UserAuthCertPlugin(BaseAuthPlugin):
|
||||
"""Used a *signed* x509 certificate's `Subject AlternativeName` or `SAN` to verify client authentication.
|
||||
|
||||
Often used for IoT devices, this method provides the most secure form of identification. A root
|
||||
|
|
|
@ -9,7 +9,7 @@ For ease of use, the [`user_mgr` command-line utility](auth_db.md/#user_mgr) to
|
|||
list clients. And the [`topic_mgr` command-line utility](auth_db.md/#topic_mgr) to add client access to
|
||||
subscribe, publish and receive messages on topics.
|
||||
|
||||
## Authentication Configuration
|
||||
# Authentication Configuration
|
||||
|
||||
::: amqtt.contrib.auth_db.UserAuthDBPlugin.Config
|
||||
options:
|
||||
|
@ -17,7 +17,7 @@ subscribe, publish and receive messages on topics.
|
|||
extra:
|
||||
class_style: "simple"
|
||||
|
||||
## Authorization Configuration
|
||||
# Authorization Configuration
|
||||
|
||||
::: amqtt.contrib.auth_db.TopicAuthDBPlugin.Config
|
||||
options:
|
||||
|
|
|
@ -65,9 +65,32 @@ async def main():
|
|||
asyncio.run(main())
|
||||
```
|
||||
|
||||
::: amqtt.contrib.cert.CertificateAuthPlugin
|
||||
## Background
|
||||
|
||||
### Root & Certificate Credentials
|
||||
Often used for IoT devices, this method provides the most secure form of identification. A root
|
||||
certificate, often referenced as a CA certificate -- either issued by a known authority (such as LetsEncrypt)
|
||||
or a self-signed certificate) is used to sign a private key and certificate for the server. Each device/client
|
||||
also gets a unique private key and certificate signed by the same CA certificate; also included in the device
|
||||
certificate is a 'SAN' or SubjectAlternativeName which is the device's unique identifier.
|
||||
|
||||
Since both server and device certificates are signed by the same CA certificate, the client can
|
||||
verify the server's authenticity; and the server can verify the client's authenticity. And since
|
||||
the device's certificate contains a x509 SAN, the server (with this plugin) can identify the device securely.
|
||||
|
||||
!!! note "URI and Client ID configuration"
|
||||
`uri_domain` configuration must be set to the same uri used to generate the device credentials
|
||||
|
||||
when a device is connecting with private key and certificate, the `client_id` must
|
||||
match the device id used to generate the device credentials.
|
||||
|
||||
Available ore three scripts to help with the key generation and certificate signing: `ca_creds`, `server_creds`
|
||||
and `device_creds`.
|
||||
|
||||
!!! note "Configuring broker & client for using Self-signed root CA"
|
||||
If using self-signed root credentials, the `cafile` configuration for both broker and client need to be
|
||||
configured with `cafile` set to the `ca.crt`.
|
||||
|
||||
## Root & Certificate Credentials
|
||||
|
||||
The process for generating a server's private key and certificate is only done once. If you have a private key & certificate --
|
||||
such as one from verifying your webserver's domain with LetsEncrypt -- that you want to use, pass them to the `server_creds` cli.
|
||||
|
@ -99,7 +122,7 @@ flowchart LR
|
|||
ssi --> skc
|
||||
```
|
||||
|
||||
### Device credentials
|
||||
## Device credentials
|
||||
|
||||
For each device, create a device id to generate a device-specific private key and certificate using the `device_creds` cli.
|
||||
Use the same CA as was used for the server (above) so the client & server recognize each other.
|
||||
|
@ -127,9 +150,9 @@ flowchart LR
|
|||
con["country, org<br/>common name<br/>& device id"] --> ccsr
|
||||
```
|
||||
|
||||
### Configuration
|
||||
## Configuration
|
||||
|
||||
::: amqtt.contrib.cert.CertificateAuthPlugin.Config
|
||||
::: amqtt.contrib.cert.UserAuthCertPlugin.Config
|
||||
options:
|
||||
show_source: false
|
||||
heading_level: 4
|
||||
|
@ -137,7 +160,7 @@ flowchart LR
|
|||
class_style: "simple"
|
||||
|
||||
|
||||
### Key and Certificate Generation
|
||||
## Key and Certificate Generation
|
||||
|
||||
::: mkdocs-typer2
|
||||
:module: amqtt.scripts.ca_creds
|
||||
|
|
|
@ -5,22 +5,21 @@ These are fully supported plugins but require additional dependencies to be inst
|
|||
`$ pip install '.[contrib]'`
|
||||
|
||||
|
||||
- Relational Database Auth<br/>
|
||||
_includes manager script to add, remove and create db entries_
|
||||
- [DB Client Authentication](auth_db.md)<br/>
|
||||
Authenticate a client's connection to broker based on entries in a relational db (mysql, postgres, maria, sqlite).<br/>
|
||||
`amqtt.contrib.auth_db.AuthUserDBPlugin`
|
||||
- [DB Client Authorization](auth_db.md)<br/>
|
||||
Determine a client's access to topics.<br/>
|
||||
`amqtt.contrib.auth_db.AuthTopicDBPlugin`
|
||||
- [Relational Database Auth](auth_db.md)<br/>
|
||||
Grant or deny access to clients based on entries in a relational db (mysql, postgres, maria, sqlite). _Includes
|
||||
manager script to add, remove and create db entries_<br/>
|
||||
- `amqtt.contrib.auth_db.UserAuthDBPlugin`
|
||||
- `amqtt.contrib.auth_db.TopicAuthDBPlugin`
|
||||
|
||||
- [HTTP Auth](http.md)<br/>
|
||||
Determine client authentication and authorization based on response from a separate HTTP server.<br/>
|
||||
`amqtt.contrib.http.HttpAuthTopicPlugin`
|
||||
Determine client authentication and/or authorization based on response from a separate HTTP server.<br/>
|
||||
- `amqtt.contrib.http.UserAuthHttpPlugin`
|
||||
- `amqtt.contrib.http.TopicAuthHttpPlugin`
|
||||
|
||||
- [LDAP Auth](ldap.md)<br/>
|
||||
Authenticate a user with an LDAP directory server.<br/>
|
||||
`amqtt.contrib.ldap.LDAPAuthPlugin`
|
||||
- `amqtt.contrib.ldap.UserAuthLdapPlugin`
|
||||
- `amqtt.contrib.ldap.TopicAuthLdapPlugin`
|
||||
|
||||
- [Shadows](shadows.md)<br/>
|
||||
Device shadows provide a persistent, cloud-based representation of the state of a device,
|
||||
|
@ -31,6 +30,16 @@ These are fully supported plugins but require additional dependencies to be inst
|
|||
- [Certificate Auth](cert.md)<br/>
|
||||
Using client-specific certificates, signed by a common authority (even if self-signed) provides
|
||||
a highly secure way of authenticating mqtt clients. Often used with IoT devices where a unique
|
||||
certificate can be initialized on initial provisioning. Includes command line utilities to generate
|
||||
root, broker and device certificates with the correct X509 attributes to enable authenticity.<br/>
|
||||
`amqtt.contrib.cert.CertificateAuthPlugin.Config`
|
||||
certificate can be initialized on initial provisioning. _Includes command line utilities to generate
|
||||
root, broker and device certificates with the correct X509
|
||||
attributes to enable authenticity._<br/>
|
||||
`amqtt.contrib.cert.UserAuthCertPlugin`
|
||||
|
||||
- [JWT Auth](jwt.md)<br/>
|
||||
Plugin to determine user authentication and topic authorization based on claims in a JWT.
|
||||
- `amqtt.contrib.jwt.UserAuthJwtPlugin` (client authentication)
|
||||
- `amqtt.contrib.jwt.TopicAuthJwtPlugin` (topic authorization)
|
||||
|
||||
- [Session Persistence](session.md)<br/>
|
||||
Plugin to store session information and retained topic messages in the event that the broker terminates abnormally.<br/>
|
||||
`amqtt.contrib.persistence.SessionDBPlugin`
|
||||
|
|
|
@ -8,7 +8,7 @@ that respond with information about client authentication and/or topic-level aut
|
|||
|
||||
Configuration of these plugins is identical (except for the uri name) so that they can be used independently, if desired.
|
||||
|
||||
## User Auth
|
||||
# User Auth
|
||||
|
||||
See the [Request and Response Modes](#request-response-modes) section below for details on `params_mode` and `response_mode`.
|
||||
|
||||
|
@ -49,7 +49,7 @@ See the [Request and Response Modes](#request-response-modes) section below for
|
|||
extra:
|
||||
class_style: "simple"
|
||||
|
||||
## Topic ACL
|
||||
# Topic ACL
|
||||
|
||||
See the [Request and Response Modes](#request-response-modes) section below for details on `params_mode` and `response_mode`.
|
||||
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
# Authentication & Authorization from JWT
|
||||
|
||||
- `amqtt.contrib.jwt.UserAuthJwtPlugin` (client authentication)
|
||||
- `amqtt.contrib.jwt.TopicAuthJwtPlugin` (topic authorization)
|
||||
|
||||
Plugin to determine user authentication and topic authorization based on claims in a JWT.
|
||||
|
||||
# User Authentication
|
||||
|
||||
For auth, the JWT should include a key as specified in the configuration as `user_clam`:
|
||||
|
||||
```python
|
||||
from datetime import datetime, UTC, timedelta
|
||||
claims = {
|
||||
"username": "example_user",
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
}
|
||||
```
|
||||
|
||||
::: amqtt.contrib.jwt.UserAuthJwtPlugin.Config
|
||||
options:
|
||||
show_source: false
|
||||
heading_level: 4
|
||||
extra:
|
||||
class_style: "simple"
|
||||
|
||||
|
||||
# Topic Authorization
|
||||
|
||||
For authorizing a client for certain topics, the token should also include claims for publish, subscribe and receive;
|
||||
keys based on how `publish_claim`, `subscribe_claim` and `receive_claim` are specified in the plugin's configuration.
|
||||
|
||||
```python
|
||||
from datetime import datetime, UTC, timedelta
|
||||
|
||||
claims = {
|
||||
"username": "example_user",
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
"publish_acl": ['my/topic/#', 'my/+/other'],
|
||||
"subscribe_acl": ['my/+/other'],
|
||||
"receive_acl": ['#']
|
||||
}
|
||||
```
|
||||
|
||||
::: amqtt.contrib.jwt.TopicAuthJwtPlugin.Config
|
||||
options:
|
||||
show_source: false
|
||||
heading_level: 4
|
||||
extra:
|
||||
class_style: "simple"
|
|
@ -8,7 +8,7 @@ for client authentication and/or topic-level authorization.
|
|||
|
||||
Authenticate a user with an LDAP directory server.
|
||||
|
||||
## User Auth
|
||||
# User Auth
|
||||
|
||||
::: amqtt.contrib.ldap.UserAuthLdapPlugin.Config
|
||||
options:
|
||||
|
@ -16,7 +16,7 @@ Authenticate a user with an LDAP directory server.
|
|||
extra:
|
||||
class_style: "simple"
|
||||
|
||||
## Topic Auth (ACL)
|
||||
# Topic Auth (ACL)
|
||||
|
||||
::: amqtt.contrib.ldap.TopicAuthLdapPlugin.Config
|
||||
options:
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
# Session Persistence
|
||||
|
||||
`amqtt.plugins.persistence.SessionDBPlugin`
|
||||
`amqtt.contrib.persistence.SessionDBPlugin`
|
||||
|
||||
Plugin to store session information and retained topic messages in the event that the broker terminates abnormally.
|
||||
|
||||
::: amqtt.plugins.persistence.SessionDBPlugin.Config
|
||||
::: amqtt.contrib.persistence.SessionDBPlugin.Config
|
||||
options:
|
||||
show_source: false
|
||||
heading_level: 4
|
||||
|
|
|
@ -171,7 +171,7 @@ state (last row in table).
|
|||
| `{ "levels": [1, 10, 4]}` | `{"levels": [1, 4, 10]}` | `{"levels": [1, 4, 10]}` |
|
||||
| `{ "brightness": 100, "mode": "eco" }` | `{ "brightness": 80 }` | `{ "brightness": 80, "mode": null }` |
|
||||
|
||||
### Configuration
|
||||
## Configuration
|
||||
|
||||
::: amqtt.contrib.shadows.ShadowPlugin.Config
|
||||
options:
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
import ast
|
||||
import json
|
||||
import pprint
|
||||
from typing import Any
|
||||
|
||||
import griffe
|
||||
from _griffe.agents.inspector import Inspector
|
||||
from _griffe.agents.nodes.runtime import ObjectNode
|
||||
from _griffe.agents.visitor import Visitor
|
||||
from _griffe.models import Attribute
|
||||
from griffe import Inspector, ObjectNode, Visitor, Attribute
|
||||
|
||||
from amqtt.contexts import default_listeners, default_broker_plugins, default_client_plugins
|
||||
from amqtt.contrib.auth_db.plugin import default_hash_scheme
|
||||
|
|
|
@ -50,6 +50,8 @@ nav:
|
|||
- LDAP Auth: plugins/ldap.md
|
||||
- Shadows: plugins/shadows.md
|
||||
- Certificate Auth: plugins/cert.md
|
||||
- JWT Auth: plugins/jwt.md
|
||||
- Session persistence: plugins/session.md
|
||||
- Reference:
|
||||
- Containerization: docker.md
|
||||
- Support: support.md
|
||||
|
|
|
@ -33,7 +33,6 @@ dependencies = [
|
|||
"passlib==1.7.4", # https://pypi.org/project/passlib
|
||||
"PyYAML==6.0.2", # https://pypi.org/project/PyYAML
|
||||
"typer==0.15.4",
|
||||
"aiohttp>=3.12.7",
|
||||
"dacite>=1.9.2",
|
||||
"psutil>=7.0.0"
|
||||
]
|
||||
|
@ -67,6 +66,7 @@ dev = [
|
|||
]
|
||||
|
||||
docs = [
|
||||
"griffe>=1.11.1",
|
||||
"markdown-callouts>=0.4",
|
||||
"markdown-exec>=1.8",
|
||||
"mkdocs>=1.6",
|
||||
|
|
|
@ -155,3 +155,12 @@ def ca_file_fixture():
|
|||
for file in temp_dir.iterdir():
|
||||
file.unlink()
|
||||
temp_dir.rmdir()
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--mock-docker",
|
||||
action="store",
|
||||
default="false",
|
||||
help="for environments where docker isn't available, mock calls which require docker",
|
||||
)
|
|
@ -13,7 +13,7 @@ import pytest
|
|||
|
||||
from amqtt.broker import BrokerContext, Broker
|
||||
from amqtt.client import MQTTClient
|
||||
from amqtt.contrib.cert import CertificateAuthPlugin
|
||||
from amqtt.contrib.cert import UserAuthCertPlugin
|
||||
from amqtt.errors import ConnectError
|
||||
from amqtt.scripts.server_creds import server_creds as get_server_creds
|
||||
from amqtt.scripts.device_creds import device_creds as get_device_creds
|
||||
|
@ -99,9 +99,9 @@ async def test_cert_plugin(ssl_object_mock, uri_domain, client_id, expected_resu
|
|||
}
|
||||
|
||||
bc = BrokerContext(broker=Broker(config=empty_cfg))
|
||||
bc.config = CertificateAuthPlugin.Config(uri_domain=uri_domain)
|
||||
bc.config = UserAuthCertPlugin.Config(uri_domain=uri_domain)
|
||||
|
||||
cert_auth_plugin = CertificateAuthPlugin(bc)
|
||||
cert_auth_plugin = UserAuthCertPlugin(bc)
|
||||
|
||||
s = Session()
|
||||
s.client_id = client_id
|
||||
|
@ -128,7 +128,7 @@ async def test_client_broker_cert_authentication(ca_creds, server_creds, device_
|
|||
},
|
||||
'plugins': {
|
||||
'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{},
|
||||
'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'},
|
||||
'amqtt.contrib.cert.UserAuthCertPlugin': {'uri_domain': 'test.amqtt.io'},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -187,7 +187,7 @@ async def test_client_broker_wrong_certs(ca_creds, server_creds, device_creds):
|
|||
},
|
||||
'plugins': {
|
||||
'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{},
|
||||
'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'},
|
||||
'amqtt.contrib.cert.UserAuthCertPlugin': {'uri_domain': 'test.amqtt.io'},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import asyncio
|
||||
import ldap
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from amqtt.broker import BrokerContext, Broker
|
||||
from amqtt.client import MQTTClient
|
||||
from amqtt.contexts import BrokerConfig, ListenerConfig, ClientConfig, Action
|
||||
from amqtt.contrib.auth_db.user_mgr_cli import user_app
|
||||
from amqtt.contrib.ldap import UserAuthLdapPlugin, TopicAuthLdapPlugin
|
||||
from amqtt.errors import ConnectError
|
||||
from amqtt.session import Session
|
||||
from tests.test_cli import broker
|
||||
|
||||
|
||||
# Pin the project name to avoid creating multiple stacks
|
||||
|
@ -29,7 +29,7 @@ def docker_compose_file(pytestconfig):
|
|||
return Path(pytestconfig.rootdir) / "tests/fixtures/ldap" / "docker-compose.yml"
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ldap_service(docker_ip, docker_services):
|
||||
def ldap_service_docker(docker_ip, docker_services):
|
||||
"""Ensure that HTTP service is up and responsive."""
|
||||
|
||||
# `port_for` takes a container port and returns the corresponding host port
|
||||
|
@ -38,6 +38,36 @@ def ldap_service(docker_ip, docker_services):
|
|||
time.sleep(2)
|
||||
return url
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ldap_service_mock():
|
||||
"""Ensure that HTTP service is up and responsive."""
|
||||
|
||||
# `port_for` takes a container port and returns the corresponding host port
|
||||
url = "ldap://localhost:0"
|
||||
mock_ldap_object = MagicMock()
|
||||
|
||||
def mock_simple_s(*args, **kwargs):
|
||||
return [ ('dn', {'uid':'jdoe', 'publishACL':[b'my/topic/one', b'my/topic/two']}), ]
|
||||
|
||||
def mock_simple_bind_s(*args):
|
||||
p = args[1]
|
||||
if p in ("adminpassword", "johndoepassword"):
|
||||
return
|
||||
raise ldap.INVALID_CREDENTIALS
|
||||
|
||||
mock_ldap_object.search_s.side_effect = mock_simple_s
|
||||
mock_ldap_object.simple_bind_s.side_effect = mock_simple_bind_s
|
||||
with patch("ldap.initialize", return_value=mock_ldap_object):
|
||||
yield url
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ldap_service(request):
|
||||
mode = request.config.getoption("--mock-docker")
|
||||
if mode:
|
||||
return request.getfixturevalue("ldap_service_mock")
|
||||
return request.getfixturevalue("ldap_service_docker")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ldap_user_plugin(ldap_service):
|
||||
ctx = BrokerContext(Broker())
|
||||
|
|
10
uv.lock
10
uv.lock
|
@ -132,7 +132,6 @@ name = "amqtt"
|
|||
version = "0.11.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "dacite" },
|
||||
{ name = "passlib" },
|
||||
{ name = "psutil" },
|
||||
|
@ -188,6 +187,7 @@ dev = [
|
|||
{ name = "types-setuptools" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "griffe" },
|
||||
{ name = "markdown-callouts" },
|
||||
{ name = "markdown-exec" },
|
||||
{ name = "mkdocs" },
|
||||
|
@ -207,7 +207,6 @@ docs = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.12.7" },
|
||||
{ name = "aiohttp", marker = "extra == 'contrib'", specifier = ">=3.12.13" },
|
||||
{ name = "aiosqlite", marker = "extra == 'contrib'", specifier = ">=0.21.0" },
|
||||
{ name = "argon2-cffi", marker = "extra == 'contrib'", specifier = ">=25.1.0" },
|
||||
|
@ -258,6 +257,7 @@ dev = [
|
|||
{ name = "types-setuptools", specifier = ">=78.1.0.20250329" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "griffe", specifier = ">=1.11.1" },
|
||||
{ name = "markdown-callouts", specifier = ">=0.4" },
|
||||
{ name = "markdown-exec", specifier = ">=1.8" },
|
||||
{ name = "mkdocs", specifier = ">=1.6" },
|
||||
|
@ -936,14 +936,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "1.7.3"
|
||||
version = "1.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/0f/9cbd56eb047de77a4b93d8d4674e70cd19a1ff64d7410651b514a1ed93d5/griffe-1.11.1.tar.gz", hash = "sha256:d54ffad1ec4da9658901eb5521e9cddcdb7a496604f67d8ae71077f03f549b7e", size = 410996, upload-time = "2025-08-11T11:38:35.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/a3/451ffd422ce143758a39c0290aaa7c9727ecc2bcc19debd7a8f3c6075ce9/griffe-1.11.1-py3-none-any.whl", hash = "sha256:5799cf7c513e4b928cfc6107ee6c4bc4a92e001f07022d97fd8dee2f612b6064", size = 138745, upload-time = "2025-08-11T11:38:33.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
Ładowanie…
Reference in New Issue