diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..1747b8f --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +min_python_version = 3.10.0 +extend-ignore = E501,E203 +ban-relative-imports = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c0c76a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +*.egg-info +.*_cache +.venv +devstate +.env* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..449f792 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +fail_fast: true +repos: + - repo: local + hooks: + - id: system + name: requirements.txt + entry: poetry export --format=requirements.txt --without-hashes --dev --output=requirements.txt + pass_filenames: false + language: system + - repo: local + hooks: + - id: system + name: black + entry: poetry run black . + pass_filenames: false + language: system + - repo: local + hooks: + - id: system + name: isort + entry: poetry run isort --profile black . + pass_filenames: false + language: system + - repo: local + hooks: + - id: system + name: mypy + entry: poetry run mypy + pass_filenames: false + language: system + - repo: local + hooks: + - id: system + name: flake8 + entry: poetry run flake8 ops_bot + pass_filenames: false + language: system \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..f69abe4 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.7 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9639c06 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +POETRY ?= poetry run +SRC := ops_bot +TESTS := tests +fmt: + $(POETRY) black $(SRC) + $(POETRY) isort --profile black $(SRC) +lint: + $(POETRY) flake8 $(SRC) + $(POETRY) bandit --silent --recursive $(SRC) +types: + $(POETRY) mypy $(SRC) + +test: + $(POETRY) pytest $(TESTS) \ No newline at end of file diff --git a/README.md b/README.md index d6ecdfe..5e26df8 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,30 @@ a bot for ops in matrix +## Dev Setup + + +`.env`: +``` +MATRIX_HOMESERVER=https://matrix.org +MATRIX_USER_ID=@YOURBOT:matrix.org +MATRIX_PASSWORD="changeme" +MATRIX_DEVICE_NAME=my-bot-server +MATRIX_VERIFY_SSL=True +MATRIX_STORE_PATH=/abs/path/to/persistent-store +BOT_ROUTING_KEYS="{\"room1\": \"!XXXX:matrix.org\", \"room2\": \"!YYYYY:matrix.org\"}" +BOT_BEARER_TOKEN="changeme" +``` + +``` +source .env +poetry install +poetry run start +``` + ## License +``` matrix-ops-bot Copyright (C) 2022 Abel Luck @@ -19,3 +41,4 @@ GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +``` \ No newline at end of file diff --git a/ops_bot/__init__.py b/ops_bot/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/ops_bot/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/ops_bot/main.py b/ops_bot/main.py new file mode 100644 index 0000000..795ed04 --- /dev/null +++ b/ops_bot/main.py @@ -0,0 +1,103 @@ +import asyncio +from typing import Any, Dict, Optional, cast + +import uvicorn +from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseSettings + +from ops_bot import pagerduty +from ops_bot.matrix import MatrixClient, MatrixClientSettings + + +class BotSettings(BaseSettings): + bearer_token: str + routing_keys: Dict[str, str] + + class Config: + env_prefix = "BOT_" + case_sensitive = False + + +app = FastAPI() +security = HTTPBearer() + + +async def get_matrix_service(request: Request) -> MatrixClient: + """A helper to fetch the matrix client from the app state""" + return cast(MatrixClient, request.app.state.matrix_client) + + +async def matrix_main(matrix_client: MatrixClient) -> None: + """Execs the matrix client asyncio task""" + workers = [asyncio.create_task(matrix_client.start())] + await asyncio.gather(*workers) + + +@app.on_event("startup") +async def startup_event() -> None: + bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8") + matrix_settings = MatrixClientSettings(_env_file=".env", _env_file_encoding="utf-8") + matrix_settings.join_rooms = list(bot_settings.routing_keys.values()) + c = MatrixClient(settings=matrix_settings) + app.state.matrix_client = c + app.state.bot_settings = bot_settings + asyncio.create_task(matrix_main(c)) + + +@app.on_event("shutdown") +async def shutdown_event() -> None: + await app.state.matrix_client.shutdown() + + +@app.get("/") +async def root() -> Dict[str, str]: + return {"message": "Hello World"} + + +def authorize( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(security) +) -> bool: + bearer_token = request.app.state.bot_settings.bearer_token + if credentials.credentials != bearer_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + return True + + +def get_destination(bot_settings: BotSettings, routing_key: str) -> Optional[str]: + return bot_settings.routing_keys.get(routing_key, None) + + +@app.post("/hook/pagerduty/{routing_key}") +async def pagerduty_hook( + request: Request, + matrix_client: MatrixClient = Depends(get_matrix_service), + auth: bool = Depends(authorize), +) -> Dict[str, str]: + payload: Any = await request.json() + room_id = get_destination( + request.app.state.bot_settings, request.path_params["routing_key"] + ) + if room_id is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Unknown routing key" + ) + msg_plain, msg_formatted = pagerduty.parse_pagerduty_event(payload) + await matrix_client.room_send( + room_id, + msg_plain, + message_formatted=msg_formatted, + ) + return {"message": msg_plain, "message_formatted": msg_formatted} + + +def start_dev() -> None: + uvicorn.run("ops_bot.main:app", port=1111, host="127.0.0.1", reload=True) + + +def start() -> None: + uvicorn.run("ops_bot.main:app", port=1111, host="0.0.0.0") # nosec B104 diff --git a/ops_bot/matrix.py b/ops_bot/matrix.py new file mode 100644 index 0000000..94c9187 --- /dev/null +++ b/ops_bot/matrix.py @@ -0,0 +1,166 @@ +import json +import logging +import pathlib +import sys +from typing import List, Optional, Protocol + +from markdown import markdown +from nio import AsyncClient, AsyncClientConfig, LoginResponse +from pydantic import BaseModel, BaseSettings + + +class ClientCredentials(BaseModel): + homeserver: str + user_id: str + device_id: str + access_token: str + + +class CredentialStorage(Protocol): + def save_config(self, config: ClientCredentials) -> None: + """Save config""" + + def read_config(self) -> ClientCredentials: + """Load config""" + + +class LocalCredentialStore: + def __init__(self, config_file_path: pathlib.Path): + self.credential_file: pathlib.Path = config_file_path + + def save(self, config: ClientCredentials) -> None: + with self.credential_file.open(mode="w") as f: + json.dump(config.dict(), f) + + def read(self) -> ClientCredentials: + with self.credential_file.open(mode="r") as f: + return ClientCredentials(**json.load(f)) + + def exists(self) -> bool: + return self.credential_file.exists() + + +class MatrixClientSettings(BaseSettings): + homeserver: str + user_id: str + password: str + device_name: str + store_path: str + join_rooms: Optional[List[str]] + verify_ssl: Optional[bool] = True + + class Config: + env_prefix = "MATRIX_" + case_sensitive = False + + +class MatrixClient: + def __init__(self, settings: MatrixClientSettings): + self.settings = settings + self.store_path = pathlib.Path(settings.store_path) + self.credential_store = LocalCredentialStore( + self.store_path.joinpath("credentials.json") + ) + + self.client: AsyncClient = None + self.client_config = AsyncClientConfig( + max_limit_exceeded=0, + max_timeouts=0, + store_sync_tokens=True, + encryption_enabled=True, + ) + + self.greeting_sent = False + + async def start(self) -> None: + await self.login() + + if self.client.should_upload_keys: + await self.client.keys_upload() + + if self.settings.join_rooms: + for room in self.settings.join_rooms: + await self.client.join(room) + + await self.client.joined_rooms() + await self.client.sync_forever(timeout=300000, full_state=True) + + def save_credentials(self, resp: LoginResponse, homeserver: str) -> None: + credentials = ClientCredentials( + homeserver=homeserver, + user_id=resp.user_id, + device_id=resp.device_id, + access_token=resp.access_token, + ) + self.credential_store.save(credentials) + + async def login_fresh(self) -> None: + self.client = AsyncClient( + self.settings.homeserver, + self.settings.user_id, + self.settings.store_path, + config=self.client_config, + ssl=self.settings.verify_ssl, + ) + + response = await self.client.login( + password=self.settings.password, device_name=self.settings.device_name + ) + + if isinstance(response, LoginResponse): + self.save_credentials(response, self.settings.homeserver) + else: + logging.error( + f'Login for "{self.settings.user_id}" via homeserver="{self.settings.homeserver}"' + ) + logging.info(f"Login failure response: {response}") + sys.exit(1) + + async def login_with_credentials(self) -> None: + credentials = self.credential_store.read() + self.client = AsyncClient( + credentials.homeserver, + credentials.user_id, + device_id=credentials.device_id, + store_path=self.store_path, + config=self.client_config, + ssl=True, + ) + + self.client.restore_login( + user_id=credentials.user_id, + device_id=credentials.device_id, + access_token=credentials.access_token, + ) + + async def login(self) -> None: + if self.credential_store.exists(): + await self.login_with_credentials() + else: + await self.login_fresh() + + async def room_send( + self, + room: str, + message: str, + message_formatted: Optional[str] = None, + ) -> None: + content = { + "msgtype": "m.text", + "body": f"{message}", + } + if message_formatted is not None: + content["format"] = "org.matrix.custom.html" + content["formatted_body"] = markdown( + message_formatted, extensions=["extra"] + ) + + await self.client.room_send( + room_id=room, + message_type="m.room.message", + content=content, + ignore_unverified_devices=True, + ) + + async def shutdown(self) -> None: + await self.client.close() diff --git a/ops_bot/pagerduty.py b/ops_bot/pagerduty.py new file mode 100644 index 0000000..ad430b9 --- /dev/null +++ b/ops_bot/pagerduty.py @@ -0,0 +1,41 @@ +import json +from typing import Any, Tuple + + +def urgency_color(urgency: str) -> str: + if urgency == "high": + return "#dc3545" + else: + return "#17a2b8" + + +def parse_pagerduty_event(payload: Any) -> Tuple[str, str]: + """ + Parses a pagerduty webhook v3 event into a human readable message. + Returns a tuple where the first item is plain text, and the second item is matrix html formatted text + """ + event = payload["event"] + # evt_id = event["id"] + # event_type = event["event_type"] + # resource_type = event["resource_type"] + # occurred_at = event["occurred_at"] + data = event["data"] + data_type = data["type"] + + if data_type == "incident": + url = data["html_url"] + status: str = data["status"] + title: str = data["title"] + service_name: str = data["service"]["summary"] + urgency: str = data.get("urgency", "high") + plain = f"{status}: on {service_name}: {title} {url}" + formatted = f"{status.upper()} on {service_name}: [{title}]({url})" + return plain, formatted + + payload_str = json.dumps(payload, sort_keys=True, indent=2) + return ( + "unhandled", + f"""**unhandled pager duty event** +
{payload_str}
+ """, + ) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..da27252 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,884 @@ +[[package]] +name = "aiofiles" +version = "0.6.0" +description = "File support for asyncio." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "aiohttp" +version = "3.8.1" +description = "Async http client/server framework (asyncio)" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotli", "cchardet"] + +[[package]] +name = "aiohttp-socks" +version = "0.7.1" +description = "Proxy connector for aiohttp" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +aiohttp = ">=2.3.2" +attrs = ">=19.2.0" +python-socks = {version = ">=2.0.0,<3.0.0", extras = ["asyncio"]} + +[[package]] +name = "aiosignal" +version = "1.2.0" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "anyio" +version = "3.6.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16)"] + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "bandit" +version = "1.7.4" +description = "Security oriented static analyser for python code." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +GitPython = ">=1.0.1" +PyYAML = ">=5.3.1" +stevedore = ">=1.20.0" + +[package.extras] +test = ["coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml", "beautifulsoup4 (>=4.8.0)", "pylint (==1.9.4)"] +toml = ["toml"] +yaml = ["pyyaml"] + +[[package]] +name = "black" +version = "22.6.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cachetools" +version = "4.2.4" +description = "Extensible memoizing collections and decorators" +category = "main" +optional = false +python-versions = "~=3.5" + +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "2.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "fastapi" +version = "0.79.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" +starlette = "0.19.1" + +[package.extras] +all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"] +dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<7.0.0)"] +test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==4.2.1)", "types-orjson (==3.6.2)", "types-dataclasses (==0.6.5)"] + +[[package]] +name = "flake8" +version = "4.0.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" + +[[package]] +name = "frozenlist" +version = "1.3.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "future" +version = "0.18.2" +description = "Clean single-source support for Python 3 and 2" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "gitdb" +version = "4.0.9" +description = "Git Object Database" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.27" +description = "GitPython is a python library used to interact with Git repositories" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[[package]] +name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "h2" +version = "4.1.0" +description = "HTTP/2 State-Machine based protocol implementation" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +hpack = ">=4.0,<5" +hyperframe = ">=6.0,<7" + +[[package]] +name = "hpack" +version = "4.0.0" +description = "Pure-Python HPACK header compression" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[[package]] +name = "hyperframe" +version = "6.0.1" +description = "HTTP/2 framing layer for Python" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "4.12.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]] +name = "jsonschema" +version = "3.2.0" +description = "An implementation of JSON Schema validation for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +attrs = ">=17.4.0" +pyrsistent = ">=0.14.0" +six = ">=1.11.0" + +[package.extras] +format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] +format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] + +[[package]] +name = "logbook" +version = "1.5.3" +description = "A logging replacement for Python" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +all = ["redis", "brotli", "pytest (>4.0)", "execnet (>=1.0.9)", "cython", "pyzmq", "pytest-cov (>=2.6)", "sqlalchemy", "jinja2"] +compression = ["brotli"] +dev = ["pytest-cov (>=2.6)", "pytest (>4.0)", "cython"] +execnet = ["execnet (>=1.0.9)"] +jinja = ["jinja2"] +redis = ["redis"] +sqlalchemy = ["sqlalchemy"] +test = ["pytest-cov (>=2.6)", "pytest (>4.0)"] +zmq = ["pyzmq"] + +[[package]] +name = "markdown" +version = "3.4.1" +description = "Python implementation of Markdown." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + +[package.extras] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "matrix-nio" +version = "0.19.0" +description = "A Python Matrix client library, designed according to sans I/O principles." +category = "main" +optional = false +python-versions = ">=3.6.1,<4.0.0" + +[package.dependencies] +aiofiles = ">=0.6.0,<0.7.0" +aiohttp = ">=3.7.4,<4.0.0" +aiohttp-socks = ">=0.7.0,<0.8.0" +atomicwrites = {version = ">=1.4.0,<2.0.0", optional = true, markers = "extra == \"e2e\""} +cachetools = {version = ">=4.2.1,<5.0.0", optional = true, markers = "extra == \"e2e\""} +future = ">=0.18.2,<0.19.0" +h11 = ">=0.12.0,<0.13.0" +h2 = ">=4.0.0,<5.0.0" +jsonschema = ">=3.2.0,<4.0.0" +logbook = ">=1.5.3,<2.0.0" +peewee = {version = ">=3.14.4,<4.0.0", optional = true, markers = "extra == \"e2e\""} +pycryptodome = ">=3.10.1,<4.0.0" +python-olm = {version = ">=3.1.3,<4.0.0", optional = true, markers = "extra == \"e2e\""} +unpaddedbase64 = ">=2.1.0,<3.0.0" + +[package.extras] +e2e = ["python-olm (>=3.1.3,<4.0.0)", "peewee (>=3.14.4,<4.0.0)", "cachetools (>=4.2.1,<5.0.0)", "atomicwrites (>=1.4.0,<2.0.0)"] + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "more-itertools" +version = "8.13.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "multidict" +version = "6.0.2" +description = "multidict implementation" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mypy" +version = "0.971" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "pbr" +version = "5.9.0" +description = "Python Build Reasonableness" +category = "dev" +optional = false +python-versions = ">=2.6" + +[[package]] +name = "peewee" +version = "3.15.1" +description = "a little orm" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycodestyle" +version = "2.8.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycryptodome" +version = "3.15.0" +description = "Cryptographic library for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pydantic" +version = "1.9.1" +description = "Data validation and settings management using python type hints" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""} +typing-extensions = ">=3.7.4.3" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pyflakes" +version = "2.4.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pyrsistent" +version = "0.18.1" +description = "Persistent/Functional/Immutable data structures" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pytest" +version = "5.4.3" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=17.4.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.extras] +checkqa-mypy = ["mypy (==v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "python-dotenv" +version = "0.20.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-olm" +version = "3.1.3" +description = "python CFFI bindings for the olm cryptographic ratchet library" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +cffi = ">=1.0.0" +future = "*" + +[[package]] +name = "python-socks" +version = "2.0.3" +description = "Core proxy (SOCKS4, SOCKS5, HTTP tunneling) functionality for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +async-timeout = {version = ">=3.0.1", optional = true, markers = "extra == \"asyncio\""} + +[package.extras] +anyio = ["anyio (>=3.3.4)"] +asyncio = ["async-timeout (>=3.0.1)"] +curio = ["curio (>=1.4)"] +trio = ["trio (>=0.16.0)"] + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "smmap" +version = "5.0.0" +description = "A pure Python implementation of a sliding window memory map manager" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "starlette" +version = "0.19.1" +description = "The little ASGI library that shines." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] + +[[package]] +name = "stevedore" +version = "4.0.0" +description = "Manage dynamic plugins for Python applications" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "termcolor" +version = "1.1.0" +description = "ANSII Color formatting for output in terminal." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "types-markdown" +version = "3.4.0" +description = "Typing stubs for Markdown" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "types-termcolor" +version = "1.1.5" +description = "Typing stubs for termcolor" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "4.3.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "unpaddedbase64" +version = "2.1.0" +description = "Encode and decode Base64 without \"=\" padding" +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[[package]] +name = "uvicorn" +version = "0.18.2" +description = "The lightning-fast ASGI server." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchfiles (>=0.13)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "yarl" +version = "1.7.2" +description = "Yet another URL library" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[[package]] +name = "zipp" +version = "3.8.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.9" +content-hash = "294fd35d1328fe80c8d9d4aa1e4eaa86433e03e6166eef0c4f6404919dd455c8" + +[metadata.files] +aiofiles = [] +aiohttp = [] +aiohttp-socks = [] +aiosignal = [] +anyio = [] +async-timeout = [] +atomicwrites = [] +attrs = [] +bandit = [] +black = [] +cachetools = [] +cffi = [] +charset-normalizer = [] +click = [] +colorama = [] +fastapi = [] +flake8 = [] +frozenlist = [] +future = [] +gitdb = [] +gitpython = [] +h11 = [] +h2 = [] +hpack = [] +hyperframe = [] +idna = [] +importlib-metadata = [] +isort = [] +jsonschema = [] +logbook = [] +markdown = [] +matrix-nio = [] +mccabe = [] +more-itertools = [] +multidict = [] +mypy = [] +mypy-extensions = [] +packaging = [] +pathspec = [] +pbr = [] +peewee = [] +platformdirs = [] +pluggy = [] +py = [] +pycodestyle = [] +pycparser = [] +pycryptodome = [] +pydantic = [] +pyflakes = [] +pyparsing = [] +pyrsistent = [] +pytest = [] +python-dotenv = [] +python-olm = [] +python-socks = [] +pyyaml = [] +six = [] +smmap = [] +sniffio = [] +starlette = [] +stevedore = [] +termcolor = [] +tomli = [] +types-markdown = [] +types-termcolor = [] +typing-extensions = [] +unpaddedbase64 = [] +uvicorn = [] +wcwidth = [] +yarl = [] +zipp = [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d4f26b6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[tool.poetry] +name = "ops_bot" +version = "0.1.0" +description = "" +authors = ["Abel Luck "] + +[tool.poetry.dependencies] +python = "^3.9" +matrix-nio = {extras = ["e2e"], version = "^0.19.0"} +fastapi = "^0.79.0" +uvicorn = "^0.18.2" +termcolor = "^1.1.0" +Markdown = "^3.4.1" +pydantic = {extras = ["dotenv"], version = "^1.9.1"} + +[tool.poetry.dev-dependencies] +pytest = "^5.2" +black = "^22.6.0" +isort = "^5.10.1" +mypy = "^0.971" +bandit = "^1.7.4" +flake8 = "^4.0.1" +types-Markdown = "^3.4.0" +types-termcolor = "^1.1.5" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +start = "ops_bot.main:start_dev" + + +[tool.black] +line-length = 88 +target-version = ['py39'] + +[tool.mypy] +files = "ops_bot,tests" +mypy_path = "ops_bot" +ignore_missing_imports = true +follow_imports = "silent" +# Ensure full coverage +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +check_untyped_defs = true + +# Restrict dynamic typing +disallow_any_generics = true +disallow_subclassing_any = true +warn_return_any = true + +# Know exactly what you're doing +warn_redundant_casts = true +warn_unused_ignores = true +warn_unused_configs = true +warn_unreachable = true +show_error_codes = true + +# Explicit is better than implicit +no_implicit_optional = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aab923a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,71 @@ +aiofiles==0.6.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +aiohttp-socks==0.7.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +aiohttp==3.8.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6" +aiosignal==1.2.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6" +anyio==3.6.1; python_version >= "3.6" and python_full_version >= "3.6.2" +async-timeout==4.0.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6" +atomicwrites==1.4.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and (python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.4.0") +attrs==21.4.0; python_full_version >= "3.6.1" and python_version >= "3.6" and python_full_version < "4.0.0" +bandit==1.7.4; python_version >= "3.7" +black==22.6.0; python_full_version >= "3.6.2" +cachetools==4.2.4; python_version >= "3.5" and python_version < "4.0" and python_full_version >= "3.6.1" and python_full_version < "4.0.0" +cffi==1.15.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +charset-normalizer==2.1.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6" +click==8.1.3; python_version >= "3.7" and python_full_version >= "3.6.2" +colorama==0.4.5; sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.6.2" and platform_system == "Windows" +fastapi==0.79.0; python_full_version >= "3.6.1" +flake8==4.0.1; python_version >= "3.6" +frozenlist==1.3.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7" +future==0.18.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +gitdb==4.0.9; python_version >= "3.7" +gitpython==3.1.27; python_version >= "3.7" +h11==0.12.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7" +h2==4.1.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +hpack==4.0.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +hyperframe==6.0.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +idna==3.3; python_full_version >= "3.6.2" and python_full_version < "4.0.0" and python_version >= "3.6" +importlib-metadata==4.12.0; python_version < "3.10" and python_version >= "3.7" +isort==5.10.1; python_full_version >= "3.6.1" and python_version < "4.0" +jsonschema==3.2.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +logbook==1.5.3; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +markdown==3.4.1; python_version >= "3.7" +matrix-nio==0.19.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +mccabe==0.6.1; python_version >= "3.6" +more-itertools==8.13.0; python_version >= "3.5" +multidict==6.0.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7" +mypy-extensions==0.4.3; python_full_version >= "3.6.2" and python_version >= "3.6" +mypy==0.971; python_version >= "3.6" +packaging==21.3; python_version >= "3.6" +pathspec==0.9.0; python_full_version >= "3.6.2" +pbr==5.9.0; python_version >= "3.8" +peewee==3.15.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +platformdirs==2.5.2; python_version >= "3.7" and python_full_version >= "3.6.2" +pluggy==0.13.1; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.5" +py==1.11.0; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5" +pycodestyle==2.8.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" +pycparser==2.21; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +pycryptodome==3.15.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +pydantic==1.9.1; python_full_version >= "3.6.1" +pyflakes==2.4.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +pyparsing==3.0.9; python_full_version >= "3.6.8" and python_version >= "3.6" +pyrsistent==0.18.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7" +pytest==5.4.3; python_version >= "3.5" +python-dotenv==0.20.0; python_full_version >= "3.6.1" and python_version >= "3.5" +python-olm==3.1.3; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +python-socks==2.0.3; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +pyyaml==6.0; python_version >= "3.7" +six==1.16.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" +smmap==5.0.0; python_version >= "3.7" +sniffio==1.2.0; python_version >= "3.6" and python_full_version >= "3.6.2" +starlette==0.19.1; python_version >= "3.6" and python_full_version >= "3.6.1" +stevedore==4.0.0; python_version >= "3.8" +termcolor==1.1.0 +tomli==2.0.1; python_full_version < "3.11.0a7" and python_full_version >= "3.6.2" and python_version >= "3.7" and python_version < "3.11" +types-markdown==3.4.0 +types-termcolor==1.1.5 +typing-extensions==4.3.0; python_version >= "3.7" and python_full_version >= "3.6.2" and python_version < "3.10" +unpaddedbase64==2.1.0; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.6.1" and python_full_version < "4.0.0" +uvicorn==0.18.2; python_version >= "3.7" +wcwidth==0.2.5; python_version >= "3.5" +yarl==1.7.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6" +zipp==3.8.1; python_version < "3.10" and python_version >= "3.7" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ops_bot.py b/tests/test_ops_bot.py new file mode 100644 index 0000000..bfca784 --- /dev/null +++ b/tests/test_ops_bot.py @@ -0,0 +1,5 @@ +from ops_bot import __version__ + + +def test_version() -> None: + assert __version__ == "0.1.0"