mirror of
https://github.com/ijaric/voice_assistant.git
synced 2025-05-24 14:33:26 +00:00
Merge pull request #2 from ijaric/feature/python-service
Python Service Template [Test]
This commit is contained in:
commit
b606a21951
2
src/python-service/.dockerignore
Normal file
2
src/python-service/.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
.venv/
|
||||
tests/
|
13
src/python-service/.gitignore
vendored
Normal file
13
src/python-service/.gitignore
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Python dependencies
|
||||
.venv/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
# Coverage reports
|
||||
/.coverage
|
||||
htmlcov/
|
||||
|
||||
# Environment variables
|
||||
.env
|
11
src/python-service/.lintstagedrc.json
Normal file
11
src/python-service/.lintstagedrc.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"*.py": [
|
||||
".venv/bin/python -m black --check",
|
||||
".venv/bin/python -m isort --check",
|
||||
".venv/bin/python -m sort_all",
|
||||
".venv/bin/python -m pyupgrade --py310-plus",
|
||||
".venv/bin/python -m pylint",
|
||||
"../node_modules/.bin/pyright"
|
||||
],
|
||||
"*.toml": [".venv/bin/toml-sort --check"]
|
||||
}
|
12
src/python-service/.prettierignore
Normal file
12
src/python-service/.prettierignore
Normal file
|
@ -0,0 +1,12 @@
|
|||
# Python dependencies
|
||||
.venv/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
# Coverage reports
|
||||
/.coverage
|
||||
|
||||
# Environment variables
|
||||
.env
|
19
src/python-service/Dockerfile
Normal file
19
src/python-service/Dockerfile
Normal file
|
@ -0,0 +1,19 @@
|
|||
FROM ghcr.io/yp-middle-python-24/python-dev:3.10-jammy-0.0.1 AS builder
|
||||
|
||||
RUN mkdir --parents /opt/app
|
||||
COPY pyproject.toml /opt/app/pyproject.toml
|
||||
COPY poetry.lock /opt/app/poetry.lock
|
||||
COPY poetry.toml /opt/app/poetry.toml
|
||||
|
||||
WORKDIR /opt/app
|
||||
RUN poetry install --no-dev
|
||||
|
||||
FROM ghcr.io/yp-middle-python-24/python:3.10-jammy-0.0.1 AS runtime
|
||||
|
||||
RUN mkdir --parents /opt/app
|
||||
COPY --from=builder /opt/app/.venv /opt/app/.venv
|
||||
COPY bin /opt/app/bin
|
||||
COPY lib /opt/app/lib
|
||||
|
||||
WORKDIR /opt/app
|
||||
CMD [".venv/bin/python", "-m", "bin.main"]
|
3
src/python-service/Makefile
Normal file
3
src/python-service/Makefile
Normal file
|
@ -0,0 +1,3 @@
|
|||
include ../../common_makefile.mk
|
||||
|
||||
PROJECT_FOLDERS = bin lib tests
|
28
src/python-service/README.md
Normal file
28
src/python-service/README.md
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Python Service Example Backend
|
||||
|
||||
Python Service Example Backend
|
||||
|
||||
## Development
|
||||
|
||||
### Global dependencies
|
||||
|
||||
- poetry
|
||||
|
||||
### Makefile commands
|
||||
|
||||
- `make init` - Initialize service
|
||||
- `make lint` - Lint service
|
||||
- `make lint-fix` - Auto-fix service
|
||||
- `make test` - Test service
|
||||
- `make clean` - Clean up service
|
||||
- `make dev-server-start` - Start dev server
|
||||
- `make test-coverage-run` - Collect test coverage data
|
||||
- `make test-coverage-report` - Show coverage report in console
|
||||
- `make test-coverage-html` - Prepare and show coverage report in browser
|
||||
- `make ci-image-build` - Build production container
|
||||
- `make ci-image-push` - Push production container to yccr
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `APP_ENV` - Application environment (development, production, etc.)
|
||||
- `APP_VERSION` - Application version
|
0
src/python-service/bin/__init__.py
Normal file
0
src/python-service/bin/__init__.py
Normal file
0
src/python-service/bin/main/__init__.py
Normal file
0
src/python-service/bin/main/__init__.py
Normal file
37
src/python-service/bin/main/__main__.py
Normal file
37
src/python-service/bin/main/__main__.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import lib.app as app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
settings = app.Settings()
|
||||
application = app.Application.from_settings(settings)
|
||||
|
||||
try:
|
||||
await application.start()
|
||||
finally:
|
||||
await application.dispose()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
asyncio.run(run())
|
||||
exit(os.EX_OK)
|
||||
except SystemExit:
|
||||
exit(os.EX_OK)
|
||||
except app.ApplicationError:
|
||||
exit(os.EX_SOFTWARE)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Exited with keyboard interruption")
|
||||
exit(os.EX_OK)
|
||||
except BaseException:
|
||||
logger.exception("Unexpected error occurred")
|
||||
exit(os.EX_SOFTWARE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
0
src/python-service/lib/__init__.py
Normal file
0
src/python-service/lib/__init__.py
Normal file
0
src/python-service/lib/api/__init__.py
Normal file
0
src/python-service/lib/api/__init__.py
Normal file
0
src/python-service/lib/api/rest/__init__.py
Normal file
0
src/python-service/lib/api/rest/__init__.py
Normal file
0
src/python-service/lib/api/rest/v1/__init__.py
Normal file
0
src/python-service/lib/api/rest/v1/__init__.py
Normal file
5
src/python-service/lib/api/rest/v1/health/__init__.py
Normal file
5
src/python-service/lib/api/rest/v1/health/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .liveness_probe import LivenessProbeHandler
|
||||
|
||||
__all__ = [
|
||||
"LivenessProbeHandler",
|
||||
]
|
18
src/python-service/lib/api/rest/v1/health/liveness_probe.py
Normal file
18
src/python-service/lib/api/rest/v1/health/liveness_probe.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
import json
|
||||
|
||||
import aiohttp.web as aiohttp_web
|
||||
|
||||
import lib.utils.aiohttp as aiohttp_utils
|
||||
|
||||
|
||||
class LivenessProbeHandler(aiohttp_utils.HandlerProtocol):
|
||||
async def process(self, request: aiohttp_web.Request) -> aiohttp_web.Response:
|
||||
return aiohttp_web.Response(
|
||||
status=200,
|
||||
body=json.dumps(obj={"status": "healthy"}),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LivenessProbeHandler",
|
||||
]
|
11
src/python-service/lib/app/__init__.py
Normal file
11
src/python-service/lib/app/__init__.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from .app import Application
|
||||
from .errors import ApplicationError, DisposeError, StartServerError
|
||||
from .settings import Settings
|
||||
|
||||
__all__ = [
|
||||
"Application",
|
||||
"ApplicationError",
|
||||
"DisposeError",
|
||||
"Settings",
|
||||
"StartServerError",
|
||||
]
|
131
src/python-service/lib/app/app.py
Normal file
131
src/python-service/lib/app/app.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import aiohttp.web as aiohttp_web
|
||||
import typing_extensions
|
||||
|
||||
import lib.api.rest.v1.health as health_handlers
|
||||
import lib.app.errors as app_errors
|
||||
import lib.app.settings as app_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class DisposableResource:
|
||||
name: str
|
||||
dispose_callback: typing.Awaitable[typing.Any]
|
||||
|
||||
|
||||
class Application:
|
||||
def __init__(
|
||||
self,
|
||||
settings: app_settings.Settings,
|
||||
aio_app: aiohttp_web.Application,
|
||||
disposable_resources: typing.Sequence[DisposableResource],
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._aio_app = aio_app
|
||||
self._disposable_resources = disposable_resources
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: app_settings.Settings) -> typing_extensions.Self:
|
||||
# Logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=settings.LOGS_MIN_LEVEL,
|
||||
format=settings.LOGS_FORMAT,
|
||||
)
|
||||
|
||||
logger.info("Initializing application")
|
||||
disposable_resources = []
|
||||
|
||||
# Global clients
|
||||
|
||||
logger.info("Initializing global clients")
|
||||
|
||||
# Clients
|
||||
|
||||
logger.info("Initializing clients")
|
||||
|
||||
# Repositories
|
||||
|
||||
logger.info("Initializing repositories")
|
||||
|
||||
# Caches
|
||||
|
||||
logger.info("Initializing caches")
|
||||
|
||||
# Services
|
||||
|
||||
logger.info("Initializing services")
|
||||
|
||||
# Handlers
|
||||
|
||||
logger.info("Initializing handlers")
|
||||
liveness_probe_handler = health_handlers.LivenessProbeHandler()
|
||||
|
||||
logger.info("Creating application")
|
||||
aio_app = aiohttp_web.Application()
|
||||
|
||||
# Routes
|
||||
aio_app.add_routes(
|
||||
[
|
||||
aiohttp_web.get(
|
||||
"/api/v1/health/liveness",
|
||||
liveness_probe_handler.process,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
application = Application(
|
||||
settings=settings,
|
||||
aio_app=aio_app,
|
||||
disposable_resources=disposable_resources,
|
||||
)
|
||||
|
||||
logger.info("Initializing application finished")
|
||||
|
||||
return application
|
||||
|
||||
async def start(self) -> None:
|
||||
logger.info("Discord server is starting")
|
||||
|
||||
try:
|
||||
await aiohttp_web._run_app(
|
||||
app=self._aio_app,
|
||||
host=self._settings.SERVER_HOST,
|
||||
port=self._settings.SERVER_PORT,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("HTTP server has been interrupted")
|
||||
except BaseException as unexpected_error:
|
||||
logger.exception("HTTP server failed to start")
|
||||
raise app_errors.StartServerError("HTTP server failed to start") from unexpected_error
|
||||
|
||||
async def dispose(self) -> None:
|
||||
logger.info("Application is shutting down...")
|
||||
dispose_errors = []
|
||||
|
||||
for resource in self._disposable_resources:
|
||||
logger.info("Disposing %s...", resource.name)
|
||||
try:
|
||||
await resource.dispose_callback
|
||||
except Exception as unexpected_error:
|
||||
dispose_errors.append(unexpected_error)
|
||||
logger.exception("Failed to dispose %s", resource.name)
|
||||
else:
|
||||
logger.info("%s has been disposed", resource.name)
|
||||
|
||||
if len(dispose_errors) != 0:
|
||||
logger.error("Application has shut down with errors")
|
||||
raise app_errors.DisposeError("Application has shut down with errors, see logs above")
|
||||
|
||||
logger.info("Application has successfully shut down")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Application",
|
||||
]
|
22
src/python-service/lib/app/errors.py
Normal file
22
src/python-service/lib/app/errors.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
import typing
|
||||
|
||||
|
||||
class ApplicationError(Exception):
|
||||
def __init__(self, message: str, *args: typing.Any) -> None:
|
||||
super().__init__(*args)
|
||||
self.message = message
|
||||
|
||||
|
||||
class DisposeError(ApplicationError):
|
||||
pass
|
||||
|
||||
|
||||
class StartServerError(ApplicationError):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApplicationError",
|
||||
"DisposeError",
|
||||
"StartServerError",
|
||||
]
|
32
src/python-service/lib/app/settings.py
Normal file
32
src/python-service/lib/app/settings.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import typing
|
||||
|
||||
import pydantic
|
||||
|
||||
LogLevel = typing.Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
|
||||
|
||||
|
||||
class Settings(pydantic.BaseSettings):
|
||||
# App
|
||||
|
||||
APP_ENV: str = "development"
|
||||
APP_NAME: str = "discord-chatbot-backend"
|
||||
APP_VERSION: str = "0.0.1"
|
||||
|
||||
# Logging
|
||||
|
||||
LOGS_MIN_LEVEL: LogLevel = "DEBUG"
|
||||
LOGS_FORMAT: str = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
|
||||
|
||||
# Server
|
||||
|
||||
SERVER_HOST: str = "localhost"
|
||||
SERVER_PORT: int = 8080
|
||||
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
return self.APP_ENV == "development"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
]
|
0
src/python-service/lib/utils/__init__.py
Normal file
0
src/python-service/lib/utils/__init__.py
Normal file
5
src/python-service/lib/utils/aiohttp/__init__.py
Normal file
5
src/python-service/lib/utils/aiohttp/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .types import HandlerProtocol
|
||||
|
||||
__all__ = [
|
||||
"HandlerProtocol",
|
||||
]
|
5
src/python-service/lib/utils/aiohttp/types/__init__.py
Normal file
5
src/python-service/lib/utils/aiohttp/types/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .handler import HandlerProtocol
|
||||
|
||||
__all__ = [
|
||||
"HandlerProtocol",
|
||||
]
|
13
src/python-service/lib/utils/aiohttp/types/handler.py
Normal file
13
src/python-service/lib/utils/aiohttp/types/handler.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
import typing
|
||||
|
||||
import aiohttp.web as aiohttp_web
|
||||
|
||||
|
||||
class HandlerProtocol(typing.Protocol):
|
||||
async def process(self, request: aiohttp_web.Request) -> aiohttp_web.Response:
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HandlerProtocol",
|
||||
]
|
1199
src/python-service/poetry.lock
generated
Normal file
1199
src/python-service/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
src/python-service/poetry.toml
Normal file
3
src/python-service/poetry.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
[virtualenvs]
|
||||
create = true
|
||||
in-project = true
|
121
src/python-service/pyproject.toml
Normal file
121
src/python-service/pyproject.toml
Normal file
|
@ -0,0 +1,121 @@
|
|||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ["py310"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
"class .*Protocol\\):",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
"if typing.TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
include = ["bin/*", "lib/*"]
|
||||
|
||||
[tool.isort]
|
||||
known_first_party = ["bin", "lib", "tests"]
|
||||
line_length = 120
|
||||
profile = "black"
|
||||
py_version = 310
|
||||
|
||||
[tool.poetry]
|
||||
authors = ["ovsds <ovsds@yandex-team.ru>"]
|
||||
description = "Python Service Example Backend"
|
||||
name = "python-service-example-backend"
|
||||
version = "0.0.1"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
aiohttp = "3.8.4"
|
||||
pydantic = {extras = ["dotenv"], version = "1.10.2"}
|
||||
python = "~3.10"
|
||||
typing-extensions = "4.5.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
black = "22.6.0"
|
||||
coverage = "6.5.0"
|
||||
isort = "5.10.1"
|
||||
pylint = "2.14.5"
|
||||
pylint-pydantic = "0.1.4"
|
||||
pylint-pytest = "1.1.2"
|
||||
pytest = "7.1.2"
|
||||
pytest-asyncio = "0.19.0"
|
||||
pytest-mock = "3.8.2"
|
||||
pyupgrade = "3.1.0"
|
||||
sort-all = "1.2.0"
|
||||
toml-sort = "0.20.0"
|
||||
|
||||
[tool.pylint]
|
||||
disable = [
|
||||
"broad-except",
|
||||
"consider-using-from-import",
|
||||
"consider-using-sys-exit",
|
||||
"duplicate-code",
|
||||
"fixme",
|
||||
"missing-docstring",
|
||||
"no-member",
|
||||
"protected-access",
|
||||
"too-few-public-methods",
|
||||
"too-many-instance-attributes",
|
||||
"too-many-locals",
|
||||
"too-many-statements",
|
||||
"unnecessary-ellipsis",
|
||||
]
|
||||
extension-pkg-allow-list = [
|
||||
"pydantic", # https://github.com/samuelcolvin/pydantic/issues/1961
|
||||
]
|
||||
ignore-paths = [
|
||||
"^.*venv/.*$",
|
||||
]
|
||||
load-plugins = [
|
||||
"pylint_pydantic",
|
||||
"pylint_pytest",
|
||||
]
|
||||
max-args = 10
|
||||
max-line-length = 120
|
||||
recursive = true
|
||||
|
||||
[tool.pylint.basic]
|
||||
argument-rgx = "^_{0,2}[a-z][a-z0-9_]*$"
|
||||
attr-rgx = "^_{0,2}[a-z][a-z0-9_]*$"
|
||||
class-attribute-rgx = "^_{0,2}[a-zA-Z][a-zA-Z0-9_]*$"
|
||||
variable-rgx = "^_{0,2}[a-z][a-z0-9_]*$"
|
||||
|
||||
[tool.pyright]
|
||||
exclude = [
|
||||
"**/__pycache__",
|
||||
]
|
||||
include = [
|
||||
"bin",
|
||||
"lib",
|
||||
"tests",
|
||||
]
|
||||
pythonPlatform = "All"
|
||||
pythonVersion = "3.10"
|
||||
reportConstantRedefinition = "none"
|
||||
reportMissingTypeStubs = "warning"
|
||||
reportPrivateUsage = "information"
|
||||
reportPropertyTypeMismatch = "warning"
|
||||
reportUninitializedInstanceVariable = "warning"
|
||||
reportUnknownMemberType = "none"
|
||||
reportUnnecessaryTypeIgnoreComment = "warning"
|
||||
reportUntypedFunctionDecorator = "warning"
|
||||
typeCheckingMode = "strict"
|
||||
useLibraryCodeForTypes = true
|
||||
venv = ".venv"
|
||||
venvPath = '.'
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = "."
|
||||
|
||||
[tool.tomlsort]
|
||||
all = true
|
||||
ignore_case = true
|
||||
in_place = true
|
0
src/python-service/tests/__init__.py
Normal file
0
src/python-service/tests/__init__.py
Normal file
0
src/python-service/tests/integration/__init__.py
Normal file
0
src/python-service/tests/integration/__init__.py
Normal file
0
src/python-service/tests/unit/__init__.py
Normal file
0
src/python-service/tests/unit/__init__.py
Normal file
0
src/python-service/tests/utils/__init__.py
Normal file
0
src/python-service/tests/utils/__init__.py
Normal file
Loading…
Reference in New Issue
Block a user