mirror of
https://github.com/dscripka/openWakeWord.git
synced 2026-08-27 18:17:20 -04:00
fixed merged conflicts
This commit is contained in:
commit
b2a3ee6c3e
28 changed files with 127 additions and 77 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*.onnx filter=lfs diff=lfs merge=lfs -text
|
|
||||||
*.tflite filter=lfs diff=lfs merge=lfs -text
|
|
||||||
|
|
@ -13,8 +13,6 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
with:
|
|
||||||
lfs: true
|
|
||||||
- name: Set up Python 3.8
|
- name: Set up Python 3.8
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit_tests_linux:
|
unit_tests_linux:
|
||||||
|
|
@ -18,8 +19,6 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
lfs: true
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
|
|
@ -42,8 +41,6 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
lfs: true
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
recursive-include openwakeword *.onnx
|
|
||||||
recursive-include openwakeword *.tflite
|
|
||||||
10
README.md
10
README.md
|
|
@ -41,16 +41,20 @@ Many thanks to [TeaPoly](https://github.com/TeaPoly/speexdsp-ns-python) for thei
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
For quick local testing, clone this repository and use the included [example script](examples/detect_from_microphone.py) to try streaming detection from a local microphone. **Important note!** The model files are stored in this repo using [git-lfs](https://git-lfs.com/); make sure it is installed on your system and if needed use `git-lfs fetch --all` to make sure the the models download correctly.
|
For quick local testing, clone this repository and use the included [example script](examples/detect_from_microphone.py) to try streaming detection from a local microphone. You can individually download pre-trained models from current and past [releases](https://github.com/dscripka/openWakeWord/releases/), or you can download them using Python (see below).
|
||||||
|
|
||||||
Adding openWakeWord to your own Python code requires just a few lines:
|
Adding openWakeWord to your own Python code requires just a few lines:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
import openwakeword
|
||||||
from openwakeword.model import Model
|
from openwakeword.model import Model
|
||||||
|
|
||||||
# Instantiate the model
|
# One-time download of all pre-trained models (or only select models)
|
||||||
|
openwakeword.utils.download_models()
|
||||||
|
|
||||||
|
# Instantiate the model(s)
|
||||||
model = Model(
|
model = Model(
|
||||||
wakeword_models=["path/to/model.onnx"], # can also leave this argument empty to load all of the included pre-trained models
|
wakeword_models=["path/to/model.tflite"], # can also leave this argument empty to load all of the included pre-trained models
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get audio data containing 16-bit 16khz PCM audio data from a file, microphone, network stream, etc.
|
# Get audio data containing 16-bit 16khz PCM audio data from a file, microphone, network stream, etc.
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,48 @@ from openwakeword.custom_verifier_model import train_custom_verifier
|
||||||
|
|
||||||
__all__ = ['Model', 'VAD', 'train_custom_verifier']
|
__all__ = ['Model', 'VAD', 'train_custom_verifier']
|
||||||
|
|
||||||
models = {
|
FEATURE_MODELS = {
|
||||||
|
"embedding": {
|
||||||
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/embedding_model.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.tflite"
|
||||||
|
},
|
||||||
|
"melspectrogram": {
|
||||||
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/melspectrogram.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.tflite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VAD_MODELS = {
|
||||||
|
"silero_vad": {
|
||||||
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/silero_vad.onnx"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/silero_vad.onnx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MODELS = {
|
||||||
"alexa": {
|
"alexa": {
|
||||||
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/alexa_v0.1.tflite")
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/alexa_v0.1.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/alexa_v0.1.tflite"
|
||||||
},
|
},
|
||||||
"hey_mycroft": {
|
"hey_mycroft": {
|
||||||
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_mycroft_v0.1.tflite")
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_mycroft_v0.1.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/hey_mycroft_v0.1.tflite"
|
||||||
},
|
},
|
||||||
"hey_jarvis": {
|
"hey_jarvis": {
|
||||||
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_jarvis_v0.1.tflite")
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_jarvis_v0.1.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/hey_jarvis_v0.1.tflite"
|
||||||
},
|
},
|
||||||
"hey_rhasspy": {
|
"hey_rhasspy": {
|
||||||
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_rhasspy_v0.1.tflite")
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_rhasspy_v0.1.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/hey_rhasspy_v0.1.tflite"
|
||||||
},
|
},
|
||||||
"timer": {
|
"timer": {
|
||||||
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/timer_v0.1.tflite")
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/timer_v0.1.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/timer_v0.1.tflite"
|
||||||
},
|
},
|
||||||
"weather": {
|
"weather": {
|
||||||
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/weather_v0.1.tflite")
|
"model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/weather_v0.1.tflite"),
|
||||||
|
"download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/weather_v0.1.tflite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +64,6 @@ model_class_mappings = {
|
||||||
|
|
||||||
def get_pretrained_model_paths(inference_framework="tflite"):
|
def get_pretrained_model_paths(inference_framework="tflite"):
|
||||||
if inference_framework == "tflite":
|
if inference_framework == "tflite":
|
||||||
return [models[i]["model_path"] for i in models.keys()]
|
return [MODELS[i]["model_path"] for i in MODELS.keys()]
|
||||||
elif inference_framework == "onnx":
|
elif inference_framework == "onnx":
|
||||||
return [models[i]["model_path"].replace(".tflite", ".onnx") for i in models.keys()]
|
return [MODELS[i]["model_path"].replace(".tflite", ".onnx") for i in MODELS.keys()]
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class Model():
|
||||||
with VAD scores above the threshold will be returned. The default value (0),
|
with VAD scores above the threshold will be returned. The default value (0),
|
||||||
disables voice activity detection entirely.
|
disables voice activity detection entirely.
|
||||||
custom_verifier_models (dict): A dictionary of paths to custom verifier models, where
|
custom_verifier_models (dict): A dictionary of paths to custom verifier models, where
|
||||||
the keys are the model names (corresponding to the openwakeword.models
|
the keys are the model names (corresponding to the openwakeword.MODELS
|
||||||
attribute) and the values are the filepaths of the
|
attribute) and the values are the filepaths of the
|
||||||
custom verifier models.
|
custom verifier models.
|
||||||
custom_verifier_threshold (float): The score threshold to use a custom verifier model. If the score
|
custom_verifier_threshold (float): The score threshold to use a custom verifier model. If the score
|
||||||
|
|
@ -85,7 +85,7 @@ class Model():
|
||||||
wakeword_model_names = []
|
wakeword_model_names = []
|
||||||
if wakeword_models == []:
|
if wakeword_models == []:
|
||||||
wakeword_models = pretrained_model_paths
|
wakeword_models = pretrained_model_paths
|
||||||
wakeword_model_names = list(openwakeword.models.keys())
|
wakeword_model_names = list(openwakeword.MODELS.keys())
|
||||||
elif len(wakeword_models) >= 1:
|
elif len(wakeword_models) >= 1:
|
||||||
for ndx, i in enumerate(wakeword_models):
|
for ndx, i in enumerate(wakeword_models):
|
||||||
if os.path.exists(i):
|
if os.path.exists(i):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:6ff566a01d12670e8d9e3c59da32651db1575d17272a601b7f8a39283dfbae3e
|
|
||||||
size 854246
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7333a317a790070a7f3432b81d9439c779481cc4ebd67c73da7174ea3cf48397
|
|
||||||
size 855312
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:70d164290c1d095d1d4ee149bc5e00543250a7316b59f31d056cff7bd3075c1f
|
|
||||||
size 1326578
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c0aea21eb84a4ce90a08c870da41b7a7173b45269e6a3207c71d67c40f3a59d8
|
|
||||||
size 1330312
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:94a13cfe60075b132f6a472e7e462e8123ee70861bc3fb58434a73712ee0d2cb
|
|
||||||
size 1271370
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:14bff778604985e1b5c19f0f7bbe477a69cf281d8db34b232b3b972411f710e2
|
|
||||||
size 1278912
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c2a311e8fa1338de89c31b3b46dc4dffd4af2f9a8d6ddead48893c2d301b1f18
|
|
||||||
size 857691
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:bf9e43136afd3ca323698820a6e32a47f885ef4c30a3b8b577ec71688a9d64d8
|
|
||||||
size 860300
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5a9b3ed3be2910e35780e097905aa9f35a9c10038df47914cf2b3ec4d670f6ea
|
|
||||||
size 204081
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:01d2526b45068f565aa3849d6ec2b7abae099154fc1b496f9ef20de9ef241fe9
|
|
||||||
size 416140
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ba2b0e0f8b7b875369a2c89cb13360ff53bac436f2895cced9f479fa65eb176f
|
|
||||||
size 1087958
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:96fa0adccb6e8cf95cb14465409a1a2898ee4a96a85bb9ed3c7eb0e68bf163e8
|
|
||||||
size 1092516
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:a35ebf52fd3ce5f1469b2a36158dba761bc47b973ea3382b3186ca15b1f5af28
|
|
||||||
size 1807522
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:371e44535470a29248b3b8f1bbbbaf2525c86417fd8f75c67fcf02ae0b9626df
|
|
||||||
size 1742475
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:21d5b0267e97df64870b7aca312e2043ebed248d365698926a115a3694ff9626
|
|
||||||
size 1743316
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8441da8e746899e8d969528d5bad5651cdd563079c05962788f77753041f60e7
|
|
||||||
size 1149158
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:4178991c7aeb76670f5a56559eb4129a6f3ae6207886db8bd8094fea7d362c3f
|
|
||||||
size 1150224
|
|
||||||
|
|
@ -25,6 +25,7 @@ from tqdm import tqdm
|
||||||
import openwakeword
|
import openwakeword
|
||||||
from numpy.lib.format import open_memmap
|
from numpy.lib.format import open_memmap
|
||||||
from typing import Union, List, Callable, Deque
|
from typing import Union, List, Callable, Deque
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
# Base class for computing audio features using Google's speech_embedding
|
# Base class for computing audio features using Google's speech_embedding
|
||||||
|
|
@ -590,6 +591,79 @@ def compute_features_from_generator(generator, n_total, clip_duration, output_fi
|
||||||
trim_mmap(output_file)
|
trim_mmap(output_file)
|
||||||
|
|
||||||
|
|
||||||
|
# Function to download files from a URL with a progress bar
|
||||||
|
def download_file(url, target_directory, file_size=None):
|
||||||
|
"""A simple function to download a file from a URL with a progress bar using only the requests library"""
|
||||||
|
local_filename = url.split('/')[-1]
|
||||||
|
|
||||||
|
with requests.get(url, stream=True) as r:
|
||||||
|
if file_size is not None:
|
||||||
|
progress_bar = tqdm(total=file_size, unit='iB', unit_scale=True, desc=f"{local_filename}")
|
||||||
|
else:
|
||||||
|
total_size = int(r.headers.get('content-length', 0))
|
||||||
|
progress_bar = tqdm(total=total_size, unit='iB', unit_scale=True, desc=f"{local_filename}")
|
||||||
|
|
||||||
|
with open(os.path.join(target_directory, local_filename), 'wb') as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
progress_bar.update(len(chunk))
|
||||||
|
|
||||||
|
progress_bar.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Function to download models from GitHub release assets
|
||||||
|
def download_models(
|
||||||
|
model_names: List[str] = [],
|
||||||
|
target_directory: str = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download the specified models from the release assets in the openWakeWord GitHub repository.
|
||||||
|
Uses the official urls in the MODELS dictionary in openwakeword/__init__.py.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_names (List[str]): The names of the models to download (e.g., hey_jarvis_v0.1). Both ONNX and
|
||||||
|
tflite models will be downloaded. If not provided (the default),
|
||||||
|
the latest versions of all models will be downloaded.
|
||||||
|
target_directory (str): The directory to save the models to. Defaults to the install location
|
||||||
|
of openWakeWord (i.e., the `resources/models` directory).
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if not isinstance(model_names, list):
|
||||||
|
raise ValueError("The model_names argument must be a list of strings")
|
||||||
|
|
||||||
|
# Always download melspectrogram and embedding models, if they don't already exist
|
||||||
|
if not os.path.exists(target_directory):
|
||||||
|
os.makedirs(target_directory)
|
||||||
|
for feature_model in openwakeword.FEATURE_MODELS.values():
|
||||||
|
if not os.path.exists(os.path.join(target_directory, feature_model["download_url"].split("/")[-1])):
|
||||||
|
download_file(feature_model["download_url"], target_directory)
|
||||||
|
download_file(feature_model["download_url"].replace(".tflite", ".onnx"), target_directory)
|
||||||
|
|
||||||
|
# Always download VAD models, if they don't already exist
|
||||||
|
for vad_model in openwakeword.VAD_MODELS.values():
|
||||||
|
if not os.path.exists(os.path.join(target_directory, vad_model["download_url"].split("/")[-1])):
|
||||||
|
download_file(vad_model["download_url"], target_directory)
|
||||||
|
|
||||||
|
# Get all model urls
|
||||||
|
official_model_urls = [i["download_url"] for i in openwakeword.MODELS.values()]
|
||||||
|
official_model_names = [i["download_url"].split("/")[-1] for i in openwakeword.MODELS.values()]
|
||||||
|
|
||||||
|
if model_names != []:
|
||||||
|
for model_name in model_names:
|
||||||
|
url = [i for i, j in zip(official_model_urls, official_model_names) if model_name in j]
|
||||||
|
if url != []:
|
||||||
|
if not os.path.exists(os.path.join(target_directory, url[0].split("/")[-1])):
|
||||||
|
download_file(url[0], target_directory)
|
||||||
|
download_file(url[0].replace(".tflite", ".onnx"), target_directory)
|
||||||
|
else:
|
||||||
|
print(official_model_urls)
|
||||||
|
for official_model_url in official_model_urls:
|
||||||
|
if not os.path.exists(os.path.join(target_directory, official_model_url.split("/")[-1])):
|
||||||
|
download_file(official_model_url, target_directory)
|
||||||
|
download_file(official_model_url.replace(".tflite", ".onnx"), target_directory)
|
||||||
|
|
||||||
|
|
||||||
# Handle deprecated arguments and naming (thanks to https://stackoverflow.com/a/74564394)
|
# Handle deprecated arguments and naming (thanks to https://stackoverflow.com/a/74564394)
|
||||||
def re_arg(kwarg_map):
|
def re_arg(kwarg_map):
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
|
|
|
||||||
6
setup.py
6
setup.py
|
|
@ -32,7 +32,8 @@ setuptools.setup(
|
||||||
'tflite-runtime>=2.8.0,<3; platform_system == "Linux"',
|
'tflite-runtime>=2.8.0,<3; platform_system == "Linux"',
|
||||||
'tqdm>=4.0,<5.0',
|
'tqdm>=4.0,<5.0',
|
||||||
'scipy>=1.3,<2',
|
'scipy>=1.3,<2',
|
||||||
'scikit-learn>=1,<2'
|
'scikit-learn>=1,<2',
|
||||||
|
'requests>=2.0,<3',
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
'test': [
|
'test': [
|
||||||
|
|
@ -44,7 +45,8 @@ setuptools.setup(
|
||||||
'types-requests',
|
'types-requests',
|
||||||
'types-PyYAML',
|
'types-PyYAML',
|
||||||
'mock>=5.1,<6',
|
'mock>=5.1,<6',
|
||||||
'types-mock>=5.1,<6'
|
'types-mock>=5.1,<6',
|
||||||
|
'types-requests>=2.0,<3'
|
||||||
],
|
],
|
||||||
'full': [
|
'full': [
|
||||||
'mutagen>=1.46.0,<2',
|
'mutagen>=1.46.0,<2',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ import scipy.io.wavfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Download models needed for tests
|
||||||
|
openwakeword.utils.download_models(model_names=["alexa_v0.1", "hey_mycroft_v0.1"])
|
||||||
|
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
class TestModels:
|
class TestModels:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
import mock
|
import mock
|
||||||
|
|
||||||
|
# Download models needed for tests
|
||||||
|
openwakeword.utils.download_models()
|
||||||
|
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
class TestModels:
|
class TestModels:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue