Initial Commit

This commit is contained in:
2022-07-12 05:44:09 -07:00
commit 57c9c70cbb
31 changed files with 2257 additions and 0 deletions

215
.gitignore vendored Normal file
View File

@@ -0,0 +1,215 @@
# ---> Sigl
assets/dist/*
config/*
docs/_build/*
!config/dev.example.py
!config/test.py
!.gitkeep
# MacOS
.DS_Store
# Databases
*.db
# Logging
log/*
# IDE
.vscode
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

13
LICENSE.md Normal file
View File

@@ -0,0 +1,13 @@
BSD 3-Clause License
Copyright (c) 2022, Asymworks, LLC All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

33
Makefile Normal file
View File

@@ -0,0 +1,33 @@
# Sigl Build and Test Scripts
export FLASK_APP := sigl.factory
shell serve db-init db-migrate db-upgrade db-downgrade db-populate : export FLASK_ENV := development
shell serve db-init db-migrate db-upgrade db-downgrade db-populate : export SIGL_CONFIG := ../config/dev.py
coverage coverage-html coverage-report test test-wip test-x : export FLASK_ENV := production
shell-psql serve-psql export : export FLASK_ENV := development
shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py
db-init :
poetry run flask db init
db-migrate :
poetry run flask db migrate
db-upgrade :
poetry run flask db upgrade
db-downgrade :
poetry run flask db downgrade
lint :
poetry run flake8
serve :
poetry run python -m sigl
shell :
poetry run flask shell
.PHONY : lint shell serve

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# Simple Grocery List (Sigl)
Simple Grocery List, or **Sigl** (pronounced "sigil") is a lightweight shopping list manager designed for shared grocery lists (or any other shopping list). Sigl is designed to be intuitive and flexible, emphasizing an ad-hoc approach to shopping lists rather than "recipe" based lists.

38
config/test.py Normal file
View File

@@ -0,0 +1,38 @@
"""Sigl Test Configuration.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
# Default Jade Tree Test Configuration
SERVER_NAME = 'test.sigl.local'
# Session and Token Keys
APP_SESSION_KEY = 'sigl-test-session-key'
APP_TOKEN_KEY = 'sigl-test-token-key'
APP_TOKEN_ISSUER = 'urn:sigl.test'
APP_TOKEN_AUDIENCE = 'urn:sigl.test'
APP_TOKEN_VALIDITY = 7200
# Development Database Settings (overridden by PyTest app_config Fixture)
DB_DRIVER = 'sqlite'
DB_FILE = 'sigl-test.db'
# Mail Configuration
MAIL_ENABLED = True
MAIL_SERVER = 'localhost'
MAIL_SENDER = 'test@localhost'
MAIL_SUPPRESS_SEND = True
# Frontend Configuration
FRONTEND_HOST = 'http://localhost'
FRONTEND_LOGIN_PATH = '/login'
FRONTEND_LOGO_PATH = '/logo.png'
FRONTEND_REG_CONFIRM_PATH = '/register/confirm'
FRONTEND_REG_CANCEL_PATH = '/register/cancel'
FRONTEND_REG_RESEND_PATH = '/register/resend'
# Email Site Information
SITE_ABUSE_MAILBOX = 'abuse@localhost'
SITE_HELP_MAILBOX = 'help@localhost'

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

91
migrations/env.py Normal file
View File

@@ -0,0 +1,91 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option(
'sqlalchemy.url',
str(current_app.extensions['migrate'].db.get_engine().url).replace(
'%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
connectable = current_app.extensions['migrate'].db.get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,56 @@
"""empty message
Revision ID: b3639db87e06
Revises:
Create Date: 2022-07-11 17:09:20.502213
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b3639db87e06'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('category', sa.String(length=128), nullable=False),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('modified_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_category'), 'products', ['category'], unique=False)
op.create_table('sigl_config',
sa.Column('key', sa.String(), nullable=False),
sa.Column('value', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_table('product_locations',
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('store', sa.String(length=128), nullable=False),
sa.Column('aisle', sa.String(length=64), nullable=False),
sa.Column('bin', sa.String(length=64), nullable=True),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('modified_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('product_id', 'store')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('product_locations')
op.drop_table('sigl_config')
op.drop_index(op.f('ix_products_category'), table_name='products')
op.drop_table('products')
# ### end Alembic commands ###

649
poetry.lock generated Normal file
View File

@@ -0,0 +1,649 @@
[[package]]
name = "alembic"
version = "1.8.0"
description = "A database migration tool for SQLAlchemy."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
Mako = "*"
SQLAlchemy = ">=1.3.0"
[package.extras]
tz = ["python-dateutil"]
[[package]]
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "dev"
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 = "dev"
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 = "bidict"
version = "0.22.0"
description = "The bidirectional mapping library for Python."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "blinker"
version = "1.4"
description = "Fast, simple object-to-object and broadcast signaling"
category = "main"
optional = false
python-versions = "*"
[[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 = "codespell"
version = "2.1.0"
description = "Codespell"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["check-manifest", "flake8", "pytest", "pytest-cov", "pytest-dependency"]
hard-encoding-detection = ["chardet"]
[[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 = "coverage"
version = "6.4.1"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
toml = ["tomli"]
[[package]]
name = "dnspython"
version = "2.2.1"
description = "DNS toolkit"
category = "main"
optional = false
python-versions = ">=3.6,<4.0"
[package.extras]
dnssec = ["cryptography (>=2.6,<37.0)"]
curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"]
doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"]
idna = ["idna (>=2.1,<4.0)"]
trio = ["trio (>=0.14,<0.20)"]
wmi = ["wmi (>=1.5.1,<2.0.0)"]
[[package]]
name = "eventlet"
version = "0.33.1"
description = "Highly concurrent networking library"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
dnspython = ">=1.15.0"
greenlet = ">=0.3"
six = ">=1.10.0"
[[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 = "flask"
version = "2.1.2"
description = "A simple framework for building complex web applications."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
click = ">=8.0"
importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""}
itsdangerous = ">=2.0"
Jinja2 = ">=3.0"
Werkzeug = ">=2.0"
[package.extras]
async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
[[package]]
name = "flask-cors"
version = "3.0.10"
description = "A Flask extension adding a decorator for CORS support"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
Flask = ">=0.9"
Six = "*"
[[package]]
name = "flask-mail"
version = "0.9.1"
description = "Flask extension for sending email"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
blinker = "*"
Flask = "*"
[[package]]
name = "flask-migrate"
version = "3.1.0"
description = "SQLAlchemy database migrations for Flask applications using Alembic."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
alembic = ">=0.7"
Flask = ">=0.9"
Flask-SQLAlchemy = ">=1.0"
[[package]]
name = "flask-socketio"
version = "5.2.0"
description = "Socket.IO integration for Flask applications"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
Flask = ">=0.9"
python-socketio = ">=5.0.2"
[[package]]
name = "flask-sqlalchemy"
version = "2.5.1"
description = "Adds SQLAlchemy support to your Flask application."
category = "main"
optional = false
python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*"
[package.dependencies]
Flask = ">=0.10"
SQLAlchemy = ">=0.8.0"
[[package]]
name = "greenlet"
version = "1.1.2"
description = "Lightweight in-process concurrent programming"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
[package.extras]
docs = ["sphinx"]
[[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 = "iniconfig"
version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing"
category = "dev"
optional = false
python-versions = "*"
[[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 = "itsdangerous"
version = "2.1.2"
description = "Safely pass data to untrusted environments and back."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "mako"
version = "1.2.1"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
MarkupSafe = ">=0.9.2"
[package.extras]
babel = ["babel"]
lingua = ["lingua"]
testing = ["pytest"]
[[package]]
name = "markupsafe"
version = "2.1.1"
description = "Safely add untrusted strings to HTML/XML markup."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "mccabe"
version = "0.6.1"
description = "McCabe checker, plugin for flake8"
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 = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[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 = "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 = "pytest"
version = "7.1.2"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
py = ">=1.8.2"
tomli = ">=1.0.0"
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "python-engineio"
version = "4.3.3"
description = "Engine.IO server and client for Python"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
asyncio_client = ["aiohttp (>=3.4)"]
client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
[[package]]
name = "python-socketio"
version = "5.7.0"
description = "Socket.IO server and client for Python"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
bidict = ">=0.21.0"
python-engineio = ">=4.3.0"
[package.extras]
asyncio_client = ["aiohttp (>=3.4)"]
client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
[[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 = "sqlalchemy"
version = "1.4.39"
description = "Database Abstraction Library"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
[package.dependencies]
greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
[package.extras]
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"]
mariadb_connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"]
mssql_pymssql = ["pymssql"]
mssql_pyodbc = ["pyodbc"]
mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"]
mysql_connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"]
postgresql_pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
postgresql_psycopg2binary = ["psycopg2-binary"]
postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"]
sqlcipher = ["sqlcipher3-binary"]
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "werkzeug"
version = "2.1.2"
description = "The comprehensive WSGI web application library."
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
watchdog = ["watchdog"]
[[package]]
name = "zipp"
version = "3.8.0"
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)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "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 = "6a8db1863ac0cd67efd7dc3acadea21dd345fc45f8d01b4ad28aa65543dc146b"
[metadata.files]
alembic = [
{file = "alembic-1.8.0-py3-none-any.whl", hash = "sha256:b5ae4bbfc7d1302ed413989d39474d102e7cfa158f6d5969d2497955ffe85a30"},
{file = "alembic-1.8.0.tar.gz", hash = "sha256:a2d4d90da70b30e70352cd9455e35873a255a31402a438fe24815758d7a0e5e1"},
]
atomicwrites = []
attrs = []
bidict = [
{file = "bidict-0.22.0-py3-none-any.whl", hash = "sha256:415126d23a0c81e1a8c584a8fb1f6905ea090c772571803aeee0a2242e8e7ba0"},
{file = "bidict-0.22.0.tar.gz", hash = "sha256:5c826b3e15e97cc6e615de295756847c282a79b79c5430d3bfc909b1ac9f5bd8"},
]
blinker = []
click = []
codespell = []
colorama = []
coverage = []
dnspython = [
{file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"},
{file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"},
]
eventlet = [
{file = "eventlet-0.33.1-py2.py3-none-any.whl", hash = "sha256:a085922698e5029f820cf311a648ac324d73cec0e4792877609d978a4b5bbf31"},
{file = "eventlet-0.33.1.tar.gz", hash = "sha256:afbe17f06a58491e9aebd7a4a03e70b0b63fd4cf76d8307bae07f280479b1515"},
]
flake8 = [
{file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
{file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
]
flask = []
flask-cors = [
{file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"},
{file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"},
]
flask-mail = []
flask-migrate = [
{file = "Flask-Migrate-3.1.0.tar.gz", hash = "sha256:57d6060839e3a7f150eaab6fe4e726d9e3e7cffe2150fb223d73f92421c6d1d9"},
{file = "Flask_Migrate-3.1.0-py3-none-any.whl", hash = "sha256:a6498706241aba6be7a251078de9cf166d74307bca41a4ca3e403c9d39e2f897"},
]
flask-socketio = [
{file = "Flask-SocketIO-5.2.0.tar.gz", hash = "sha256:19c3d0cea49c53505fa457fedc133b32cb6eeaaa30d28cdab9d6ca8f16045427"},
{file = "Flask_SocketIO-5.2.0-py3-none-any.whl", hash = "sha256:c82672b843fa271ec2961d356c608bc94a730660ac73a623bddb66c4b3d72215"},
]
flask-sqlalchemy = []
greenlet = [
{file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
{file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"},
{file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"},
{file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"},
{file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"},
{file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"},
{file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"},
{file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"},
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"},
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"},
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"},
{file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"},
{file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"},
{file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"},
{file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"},
{file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"},
{file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"},
{file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"},
{file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"},
{file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"},
{file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"},
{file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"},
{file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"},
{file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"},
{file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"},
{file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"},
{file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"},
{file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"},
{file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"},
{file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"},
{file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"},
{file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"},
{file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"},
{file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"},
{file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"},
]
importlib-metadata = []
iniconfig = []
isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
]
itsdangerous = []
jinja2 = []
mako = [
{file = "Mako-1.2.1-py3-none-any.whl", hash = "sha256:df3921c3081b013c8a2d5ff03c18375651684921ae83fd12e64800b7da923257"},
{file = "Mako-1.2.1.tar.gz", hash = "sha256:f054a5ff4743492f1aa9ecc47172cb33b42b9d993cffcc146c9de17e717b0307"},
]
markupsafe = []
mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
]
packaging = []
pluggy = []
py = []
pycodestyle = [
{file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
{file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
]
pyflakes = [
{file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
{file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
]
pyparsing = []
pytest = []
python-engineio = [
{file = "python-engineio-4.3.3.tar.gz", hash = "sha256:18474c452894c60590b2d2339d6c81b93fb9857f1be271a2e91fb2707eb4095d"},
{file = "python_engineio-4.3.3-py3-none-any.whl", hash = "sha256:e660fcbac7497f105310d933987d3a82d2e677240a6b493c0a514aa7f91d3b07"},
]
python-socketio = [
{file = "python-socketio-5.7.0.tar.gz", hash = "sha256:82e3c45baa51f2180f176e5e1c4232a4452f7545a3fe3156d093fa7e5890e816"},
{file = "python_socketio-5.7.0-py3-none-any.whl", hash = "sha256:874eeba29129618b30189ecb277e2d5e646c39e50b1a997289c0473ee6c1f5c8"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
sqlalchemy = []
tomli = []
werkzeug = []
zipp = []

35
pyproject.toml Normal file
View File

@@ -0,0 +1,35 @@
[tool.poetry]
name = "sigl"
version = "0.1.0"
description = "Simple Grocery List"
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
license = "BSD-3-Clause"
[tool.poetry.dependencies]
python = "^3.9"
Flask = "^2.1.2"
Flask-SQLAlchemy = "^2.5.1"
Flask-Mail = "^0.9.1"
Flask-SocketIO = "^5.2.0"
eventlet = "^0.33.1"
Flask-Cors = "^3.0.10"
alembic = "^1.8.0"
SQLAlchemy = "^1.4.39"
Flask-Migrate = "^3.1.0"
[tool.poetry.dev-dependencies]
coverage = "^6.4.1"
flake8 = "^4.0.1"
pytest = "^7.1.2"
isort = "^5.10.1"
codespell = "^2.1.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.isort]
# https://github.com/PyCQA/isort/wiki/isort-Settings
profile = "black"
combine_as_imports = true
force_sort_within_sections = true

5
sigl/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Sigl Application Package.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""

12
sigl/__main__.py Normal file
View File

@@ -0,0 +1,12 @@
"""Sigl Application Entry Point.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from .factory import create_app
from .socketio import socketio
app = create_app()
socketio.run(app)

39
sigl/cli.py Normal file
View File

@@ -0,0 +1,39 @@
"""Sigl Flask Shell Helpers.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
def init_shell(): # pragma: no cover
"""Initialize the Flask Shell Context."""
import datetime
import sqlalchemy as sa
from sigl.database import db
from sigl.domain.models import (
Product,
ProductLocation,
)
return {
# Imports
'datetime': datetime,
'sa': sa,
# Globals
'db': db,
'session': db.session,
# Models
'Product': Product,
'ProductLocation': ProductLocation,
}
def init_cli(app):
"""Register the Shell Helpers with the Application."""
app.shell_context_processor(init_shell)
# Notify Initialization Complete
app.logger.debug('CLI Initialized')

84
sigl/database/__init__.py Normal file
View File

@@ -0,0 +1,84 @@
"""Sigl Database Package.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import re
from alembic.script import ScriptDirectory
from flask import current_app
from sqlalchemy import inspect
from sigl.exc import Error
from .globals import db, migrate
from .util import make_uri
__all__ = ('db', 'migrate', 'init_db')
def check_database():
"""Check that the database schema is initialized."""
if current_app.config.get('_SIGL_DB_NEEDS_INIT'):
raise Error('Database is not initialized')
def init_db(app):
"""Initialize the Global Database Object for the Application."""
# Set SQLalchemy Defaults (suppresses warning)
app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)
# Load SQLalchemy URI
if 'SQLALCHEMY_DATABASE_URI' not in app.config:
if 'DB_URI' in app.config:
app.config['SQLALCHEMY_DATABASE_URI'] = app.config['DB_URI']
else:
app.config['SQLALCHEMY_DATABASE_URI'] = make_uri(app)
# Initialize Object Relational Mapping
from .orm import init_orm
init_orm()
# Log the URI (masked)
app.logger.debug(
'Starting SQLalchemy with URI: "%s"', re.sub(
r':([.+]|[0-9a-z\'_-~]|%[0-9A-F]{2})+@',
':(masked)@',
app.config['SQLALCHEMY_DATABASE_URI'],
flags=re.I
)
)
# Initialize Database
db.init_app(app)
migrate.init_app(app, db)
# Check the Database Revision
app.config['_SIGL_DB_NEEDS_INIT'] = True
app.config['_SIGL_DB_NEEDS_UPGRADE'] = False
app.config['_SIGL_DB_HEAD_REV'] = None
app.config['_SIGL_DB_CUR_REV'] = None
with app.app_context():
if inspect(db.engine).has_table('alembic_version'):
app.config['_SIGL_DB_NEEDS_INIT'] = False
db_version_sql = db.text('select version_num from alembic_version')
db_version = db.engine.execute(db_version_sql).scalar()
app.config['_SIGL_DB_CUR_REV'] = db_version
script_dir = ScriptDirectory.from_config(migrate.get_config())
for rev in script_dir.walk_revisions():
if rev.is_head:
app.config['_SIGL_DB_HEAD_REV'] = rev.revision
if rev.revision == db_version:
app.config['_SIGL_DB_NEEDS_UPGRADE'] = not rev.is_head
# Register Database Setup Hook
app.before_request(check_database)
# Notify Initialization
app.logger.debug('Database Initialized')
# Return Success
return True

14
sigl/database/globals.py Normal file
View File

@@ -0,0 +1,14 @@
"""Sigl Database Global Objects.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
#: Global Database Object for Models
db = SQLAlchemy()
#: Global Alembic Migration Object
migrate = Migrate()

34
sigl/database/orm.py Normal file
View File

@@ -0,0 +1,34 @@
"""Sigl Database ORM Setup.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from sigl.domain.models import (
Product,
ProductLocation,
)
from .globals import db
from .tables import (
product_locations,
products,
)
__all__ = ('init_orm', )
def init_orm():
"""Initialize the Sigl ORM."""
# Products
db.mapper(Product, products, properties={
'locations': db.relationship(
ProductLocation,
backref='product',
cascade='all, delete-orphan',
),
})
# Product Locations
db.mapper(ProductLocation, product_locations)

56
sigl/database/tables.py Normal file
View File

@@ -0,0 +1,56 @@
"""Sigl Database Tables.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from .globals import db
#: Sigl Server Configuration Table
sigl_config = db.Table(
'sigl_config',
# Key/Value Store
db.Column('key', db.String, primary_key=True),
db.Column('value', db.String(128)),
)
#: Product Table
products = db.Table(
'products',
# Primary Key
db.Column('id', db.Integer, primary_key=True),
# Product Attributes
db.Column('name', db.String(128), nullable=False),
db.Column('category', db.String(128), nullable=False, index=True),
# Mixin Columns
db.Column('notes', db.String(), default=None),
db.Column('created_at', db.DateTime(), default=None),
db.Column('modified_at', db.DateTime(), default=None),
)
#: Product Location Table
product_locations = db.Table(
'product_locations',
# Primary Keys
db.Column(
'product_id',
db.ForeignKey('products.id'),
nullable=False,
primary_key=True,
),
db.Column('store', db.String(128), nullable=False, primary_key=True),
# Location Attributes
db.Column('aisle', db.String(64), nullable=False),
db.Column('bin', db.String(64), default=None),
# Mixin Columns
db.Column('notes', db.String(), default=None),
db.Column('created_at', db.DateTime(), default=None),
db.Column('modified_at', db.DateTime(), default=None),
)

78
sigl/database/util.py Normal file
View File

@@ -0,0 +1,78 @@
"""Sigl Database Utilities.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import os
from urllib.parse import quote_plus
from sigl.exc import ConfigError
__all__ = ('make_uri', )
def make_uri(app):
'''
Assembles the Database URI from Application Configuration values.
:param app: :class:`Flask` application object
'''
if 'DB_DRIVER' not in app.config:
raise ConfigError(
'DB_DRIVER must be defined in application configuration',
config_key='DB_DRIVER'
)
# SQLite
if app.config['DB_DRIVER'] == 'sqlite':
if 'DB_FILE' not in app.config:
raise ConfigError(
'DB_FILE must be defined in application configuration for '
'SQLite Database',
config_key='DB_FILE'
)
return 'sqlite:///{db_file}'.format(
db_file=os.path.abspath(app.config['DB_FILE']),
)
# Database Servers (note only MySQL and PostgreSQL are tested)
uri_auth = ''
uri_port = ''
for k in ('DB_HOST', 'DB_NAME'):
if k not in app.config:
raise ConfigError(
f'{k} must be defined in application configuration',
config_key=k
)
# Assemble Port Suffix
if 'DB_PORT' in app.config:
uri_port = ':{}'.format(app.config['DB_PORT'])
# Assemble Authentication Portion
if 'DB_USERNAME' in app.config:
if 'DB_PASSWORD' in app.config:
uri_auth = '{username}:{password}@'.format(
username=quote_plus(app.config['DB_USERNAME']),
password=quote_plus(app.config['DB_PASSWORD']),
)
else:
uri_auth = '{username}@'.format(
username=quote_plus(app.config['DB_USERNAME']),
)
elif 'DB_PASSWORD' in app.config:
raise ConfigError(
'DB_USERNAME must be defined if DB_PASSWORD is set in '
'application configuration',
config_key='DB_USERNAME'
)
return '{driver}://{auth}{host}{port}/{name}'.format(
driver=app.config['DB_DRIVER'],
auth=uri_auth,
host=app.config['DB_HOST'],
port=uri_port,
name=app.config['DB_NAME'],
)

5
sigl/domain/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Sigl Domain Package.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""

View File

@@ -0,0 +1,11 @@
"""Sigl Domain Models.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from .product import Product, ProductLocation
__all__ = (
'Product', 'ProductLocation',
)

View File

@@ -0,0 +1,41 @@
"""Sigl Domain Model Mixin Classes.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from dataclasses import dataclass
from datetime import datetime
@dataclass
class NotesMixin:
'''
Domain Model Mixin Class for objects with Notes
.. :py:attr:: notes
:property:
Adds a column to the database object with type
:py:class:`~sqlalchemy.types.Text` which is intended for the user to
add notes associated with the object.
'''
notes: str = None
@dataclass
class TimestampMixin:
'''
Domain Model Mixin Class for Timestamped Objects
.. :py:attr:: created_at
:property:
Adds a ``created_at`` column to the database object with type
:py:class:`datetime.datetime`
.. :py:attr:: modified_at
:property:
Adds a ``modified_at`` column to the database object with type
:py:class:`datetime.datetime`
'''
created_at: datetime = datetime.utcnow()
modified_at: datetime = None
def set_modified_at(self):
self.modified_at = datetime.utcnow()

View File

@@ -0,0 +1,42 @@
"""Sigl Product Domain Model.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from dataclasses import dataclass
from typing import List
from .mixins import NotesMixin, TimestampMixin
__all__ = ('Product', 'ProductLocation')
@dataclass
class Product(NotesMixin, TimestampMixin):
"""Information about a single Product.
This class contains information about a single Product that can be on a
shopping list, including the Product Name, Category, Notes, and Product
Location in one or more stores.
"""
name: str = None
category: str = None
# Populated by ORM
# locations: List['ProductLocation'] = []
@dataclass
class ProductLocation(NotesMixin):
"""Location of a Product in a Store.
Stores the location of a Product within a store using an aisle and bin
location system.
"""
store: str = None
aisle: str = None
bin: str = None
# Relationship Fields
product: 'Product' = None

35
sigl/exc.py Normal file
View File

@@ -0,0 +1,35 @@
"""Sigl Exception Classes.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
__all__ = (
'ConfigError',
'Error',
)
class Error(Exception):
"""Base Class for Sigl Errors."""
default_code = 500
def __init__(self, *args, **kwargs):
"""Class Constructor."""
self.status_code = kwargs.pop('status_code', self.__class__.default_code)
super().__init__(*args, **kwargs)
class ConfigError(Error):
"""Exception raised for invalid or missing configuration values.
.. attribute:: config_key
:type: str
Configuration key which is missing or invalid
"""
def __init__(self, *args, config_key=None):
"""Class Constructor."""
super().__init__(*args)
self.config_key = config_key

149
sigl/factory.py Normal file
View File

@@ -0,0 +1,149 @@
"""Sigl Application Factory.
The :mod:`sigl.factory` module contains a method :func:`create_app` which
implements the Flask `Application Factory`_ pattern to create the application
object dynamically.
Application default configuration values are found in the
:mod:`sigl.settings` module.
.. _`Application Factory`:
https://flask.palletsprojects.com/en/2.1.x/patterns/appfactories/
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import configparser
import os
import pathlib
from flask import Flask
def read_version():
"""Read the Name and Version String from pyproject.toml."""
# Search for pyproject.toml
d = pathlib.Path(__file__)
name = None
version = None
while d.parent != d:
d = d.parent
path = d / 'pyproject.toml'
if path.exists():
# Use configparser to parse toml like INI to avoid dependency on
# tomlkit or similar
config = configparser.ConfigParser()
config.read(str(path))
if 'tool.poetry' in config:
name = config['tool.poetry'].get('name').strip('"\'')
version = config['tool.poetry'].get('version').strip('"\'')
return (name, version)
return (None, None)
def create_app(app_config=None, app_name=None):
"""Create and Configure the Flask Application.
Create and Configure the Application with the Flask `Application Factory`_
pattern. The `app_config` dictionary will override configuration keys set
via other methods, and is intended primarily for use in test frameworks to
provide a predictable configuration for testing.
Args:
app_config: Configuration override values which are applied after all
other configuration sources have been loaded
app_name: Application name override
Returns:
Flask application
"""
app = Flask(
'sigl',
template_folder='templates'
)
# Load Application Name and Version from pyproject.toml
pkg_name, pkg_version = read_version()
app.config['APP_NAME'] = pkg_name
app.config['APP_VERSION'] = pkg_version
# Load Default Settings
app.config.from_object('sigl.settings')
# Load Configuration File from Environment
if 'SIGL_CONFIG' in os.environ:
app.config.from_envvar('SIGL_CONFIG')
# Load Configuration Variables from Environment
for k, v in os.environ.items():
if k.startswith('SIGL_') and k != 'SIGL_CONFIG':
app.config[k[5:]] = v
# Load Factory Configuration
if app_config is not None:
if isinstance(app_config, dict):
app.config.update(app_config)
elif app_config.endswith('.py'):
app.config.from_pyfile(app_config)
# Setup Secret Keys
app.config['SECRET_KEY'] = app.config.get('APP_SESSION_KEY', None)
# Override Application Name
if app_name is not None:
app.name = app_name
try:
# Initialize Logging
from .logging import init_logging
init_logging(app)
app.logger.info('{} starting up as "{}"'.format(
app.config['APP_NAME'],
app.name
))
# Initialize Database
from .database import init_db
init_db(app)
if app.config.get('_SIGL_DB_NEEDS_INIT', True):
message = 'The database schema has not been initialized or the ' \
'database is corrupt. Please initialize the database using ' \
"'flask db upgrade' and restart the server."
app.logger.error(message)
if app.config.get('_SIGL_DB_NEEDS_UPGRADE'):
message = 'The database schema is outdated (current version: {}, ' \
'newest version: {}). Please update the database using ' \
"'flask db upgrade' and restart the server."
app.logger.warning(message)
# Setup E-mail
from .mail import init_mail
init_mail(app)
# Initialize CLI
from .cli import init_cli
init_cli(app)
# Initialize Web Sockets
from .socketio import init_socketio
init_socketio(app)
# Startup Complete
app.logger.info('{} startup complete'.format(app.config['APP_NAME']))
# Return Application
return app
except Exception as e:
# Ensure startup exceptions get logged
app.logger.exception(
'Startup Error (%s): %s',
e.__class__.__name__,
str(e)
)
raise e

335
sigl/logging.py Normal file
View File

@@ -0,0 +1,335 @@
"""Sigl Application Logging.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import logging
import sys
import traceback
from flask import has_request_context, render_template, request
from flask.logging import default_handler, wsgi_errors_stream
from flask_mail import Message
from sigl.exc import ConfigError
from sigl.mail import mail
__all__ = (
'MailHandler',
'NoTraceFormatter',
'RequestFormatter',
'init_logging',
)
LEVELS = {
'PANIC': logging.CRITICAL, # noqa: E241
'ALERT': logging.CRITICAL, # noqa: E241
'CRITICAL': logging.CRITICAL, # noqa: E241
'CRIT': logging.CRITICAL, # noqa: E241
'ERROR': logging.ERROR, # noqa: E241
'ERR': logging.ERROR, # noqa: E241
'WARNING': logging.WARNING, # noqa: E241
'WARN': logging.WARNING, # noqa: E241
'NOTICE': logging.INFO, # noqa: E241
'INFO': logging.INFO, # noqa: E241
'DEBUG': logging.DEBUG, # noqa: E241
}
def lookup_level(level):
'''Lookup a Logging Level by Python Name or syslog Name'''
if isinstance(level, int):
return level
return LEVELS.get(str(level).upper(), logging.NOTSET)
class MailHandler(logging.Handler):
'''
Custom Log Handler class which acts like :class:`logging.SMTPHandler` but
uses :mod:`flask_mail` and :func:`flask.render_template` on the back end
to send Jinja templated HTML messages. The :obj:`sigl.mail.mail` object
must be available and initialized, so this handler should not be used prior
to calling :meth:`flask_mail.Mail.init_app`.
'''
def __init__(self, sender, recipients, subject, text_template=None,
html_template=None, **kwargs):
'''
Initialize the Handler.
Initialize the instance with the sender, recipient list, and subject
for the email. To use Jinja templates, pass a template path to the
``text_template`` and ``html_template`` parameters. The templates
will be called with the following context items set:
.. data:: record
:type: :class:`python:logging.LogRecord`
Log Record which was sent to the logger
.. data:: formatted_record
:type: str
Formatted log record from :meth:`python:logging.Handler.format`
.. data:: request
:type: :class:`flask.Request`
Flask request object (or None if not available)
.. data:: user
:type: :class:`sigl.models.User`
User object (or :class:`flask_login.AnonymousUserMixin`)
Additional template context variables may be passed to the handler
constructor as keyword arguments.
:param sender: Email sender address
:type sender: str
:param recipients: Email recipient addresses
:type recipients: str or list
:param subject: Email subject
:type subject: str
:param text_template: Path to Jinja2 Template for plain-text message
:type text_template: str
:param html_template: Path to Jinja2 Template for HTML message
:type html_template: str
:param kwargs: Additional variables to pass to templates
:type kwargs: dict
'''
super(MailHandler, self).__init__()
self.sender = sender
if isinstance(recipients, str):
recipients = [recipients]
self.recipients = recipients
self.subject = subject
self.text_template = text_template
self.html_template = html_template
self.template_context = kwargs
def getSubject(self):
'''
Determine the subject for the email. If you want to override the
subject line with something record-dependent, override this method.
'''
return self.subject
def emit(self, record):
'''Format the record and send it to the specified recipients'''
try:
request_obj = None
if has_request_context():
request_obj = request
# Load stack trace (if an exception is being processed)
exc_type, exc_msg, stack_trace = sys.exc_info()
# Format Record
formatted_record = self.format(record)
text_body = formatted_record
html_body = None
# Render Text Body
if self.text_template is not None:
text_body = render_template(
self.text_template,
record=record,
request=request_obj,
formatted_record=formatted_record,
exc_class='None' if not exc_type else exc_type.__name__,
exc_msg=exc_msg,
stack_trace=traceback.extract_tb(stack_trace),
**self.template_context,
)
# Render HTML Body
if self.html_template is not None:
html_body = render_template(
self.html_template,
record=record,
request=request_obj,
formatted_record=formatted_record,
exc_class='None' if not exc_type else exc_type.__name__,
exc_msg=exc_msg,
stack_trace=traceback.extract_tb(stack_trace),
**self.template_context,
)
# Send the message with Flask-Mail
msg = Message(
self.getSubject(),
sender=self.sender,
recipients=self.recipients,
)
msg.body = text_body
msg.html = html_body
mail.send(msg)
except Exception: # pragma: no cover
self.handleError(record)
class RequestInjectorMixin(object):
'''
Inject the Flask :attr:`flask.Flask.request` object into the logger record
as well as shortcut getters for :attr:`flask.Request.url` and
:attr:`flask.Request.remote_addr`
'''
def _injectRequest(self, record):
if has_request_context():
record.request = request
record.remote_addr = request.remote_addr
record.url = request.url
else:
record.request = None
record.remote_addr = None
record.url = None
return record
class NoTraceFormatter(logging.Formatter, RequestInjectorMixin):
'''
Custom Log Formatter which suppresses automatic printing of stack trace
information even when :attr:`python:logging.LogRecord.exc_info` is set.
This wholly overrides :meth:`python:logging.Formatter.format`.
The Flask :class:`~flask.Flask.Request` object is also included into the
logger record as well as shortcut getters for :attr:`flask.Request.url`
and :attr:`flask.Request.remote_addr`
'''
def format(self, record):
'''
Format the specified record as text.
The record's attribute dictionary is used as the operand to a string
formatting operation which yields the returned string. Before
formatting the dictionary, a couple of preparatory steps are carried
out. The message attribute of the record is computed using
:meth:`python:logging.LogRecord.getMessage`. If the formatting string
uses the time (as determined by a call to
:meth:`python:logging.LogRecord.usesTime`),
:meth:`python:logging.LogRecord.formatTime` is called to format the
event time.
'''
record = self._injectRequest(record)
record.message = record.getMessage()
if self.usesTime():
record.asctime = self.formatTime(record, self.datefmt)
return self.formatMessage(record)
class RequestFormatter(logging.Formatter, RequestInjectorMixin):
'''
Custom Log Formatter which includes the Flask :attr:`flask.Flask.request`
object into the logger record as well as shortcut getters for
:attr:`flask.Request.url` and :attr:`flask.Request.remote_addr`
'''
def format(self, record):
'''
Format the specified record as text.
The record's attribute dictionary is used as the operand to a string
formatting operation which yields the returned string. Before
formatting the dictionary, a couple of preparatory steps are carried
out. The message attribute of the record is computed using
:meth:`python:logging.LogRecord.getMessage`. If the formatting string
uses the time (as determined by a call to
:meth:`python:logging.LogRecord.usesTime`),
:meth:`python:logging.LogRecord.formatTime` is called to format the
event time.
'''
record = self._injectRequest(record)
return super(RequestFormatter, self).format(record)
def init_email_logger(app, root_logger):
'''Install the Email Log Handler to the Root Logger'''
for k in ('MAIL_SERVER', 'MAIL_SENDER', 'LOG_EMAIL_ADMINS',
'LOG_EMAIL_SUBJECT'):
if k not in app.config:
raise ConfigError(
'{} must be defined in the application configuration in '
'order to send logs to an email recipient.'.format(k),
config_key=k
)
mail_handler = MailHandler(
sender=app.config.get('MAIL_SENDER', None),
recipients=app.config['LOG_EMAIL_ADMINS'],
subject=app.config['LOG_EMAIL_SUBJECT'],
text_template=app.config.get('LOG_EMAIL_BODY_TMPL', None),
html_template=app.config.get('LOG_EMAIL_HTML_TMPL', None),
)
mail_handler.setLevel(
lookup_level(app.config.get('LOG_EMAIL_LEVEL', logging.ERROR))
)
mail_handler.setFormatter(NoTraceFormatter(
app.config.get('LOG_EMAIL_FORMAT', None)
))
root_logger.addHandler(mail_handler)
# Dump Logger Configuration to Debug
app.logger.debug('Installed Email Log Handler')
app.logger.debug(
'Email Log Recipients: %s', ', '.join(mail_handler.recipients)
)
app.logger.debug('Email Log Subject: %s', mail_handler.subject)
app.logger.debug(
'Email Log Templates: %s, %s',
mail_handler.text_template,
mail_handler.html_template,
)
def init_logging(app):
'''
This sets up the application logging infrastructure, including console or
WSGI server logging as well as email logging for production server errors.
'''
logging_dest = app.config.get('LOGGING_DEST', 'wsgi').lower()
logging_stream = wsgi_errors_stream
if logging_dest == 'stdout':
logging_stream = sys.stdout
elif logging_dest == 'stderr':
logging_stream = sys.stderr
elif logging_dest != 'wsgi':
raise ConfigError(
'LOGGING_DEST must be set to wsgi, stdout, or stderr (is set '
'to {})'.format(logging_dest)
, config_key='LOGGING_DEST'
)
logging_level = lookup_level(app.config.get('LOGGING_LEVEL', None))
logging_format = app.config.get('LOGGING_FORMAT', None)
logging_backtrace = app.config.get('LOGGING_BACKTRACE', True)
if not isinstance(logging_backtrace, bool):
raise ConfigError(
'LOGGING_BACKTRACE must be a boolean value, found {}'
.format(type(logging_backtrace).__name__),
config_key='LOGGING_BACKTRACE'
)
fmt_class = RequestFormatter if logging_backtrace else NoTraceFormatter
app_handler = logging.StreamHandler(logging_stream)
app_handler.setFormatter(fmt_class(logging_format))
app_handler.setLevel(logging_level)
# Install Log Handler and Formatter on Flask Logger
app.logger.setLevel(logging_level)
app.logger.addHandler(app_handler)
# Remove Flask Default Log Handler
app.logger.removeHandler(default_handler)
# Notify Logging Initialized
app.logger.debug('Logging Boostrapped')
app.logger.debug('Installed %s Log Handler', logging_dest)
# Setup the Mail Log Handler if configured
if not app.debug or app.config.get('DBG_FORCE_LOGGERS', False):
if app.config.get('LOG_EMAIL', False):
init_email_logger(app, app.logger)

69
sigl/mail.py Normal file
View File

@@ -0,0 +1,69 @@
"""Sigl Email Support.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask import current_app
from flask_mail import Mail, Message
from sigl.exc import ConfigError
mail = Mail()
__all__ = ('init_mail', 'mail', 'send_email')
def init_mail(app):
"""Register the Flask-Mail object with the Application."""
if not app.config.get('MAIL_ENABLED', False):
app.logger.debug('Skipping mail setup (disabled)')
return
required_keys = (
'MAIL_SERVER', 'MAIL_SENDER', 'SITE_ABUSE_MAILBOX', 'SITE_HELP_MAILBOX'
)
for k in required_keys:
if k not in app.config:
raise ConfigError(
'{} must be defined in the application configuration'
.format(k),
config_key=k
)
mail.init_app(app)
# Add Site Mailboxes to templates
app.jinja_env.globals.update(
site_abuse_mailbox=app.config['SITE_ABUSE_MAILBOX'],
site_help_mailbox=app.config['SITE_HELP_MAILBOX'],
)
# Dump Configuration to Debug
app.logger.debug(
'Mail Initialized with server %s and sender %s',
app.config['MAIL_SERVER'],
app.config['MAIL_SENDER'],
)
def send_email(subject, recipients, body, html=None, sender=None):
"""Send an Email Message using Flask-Mail."""
recip_list = [recipients] if isinstance(recipients, str) else recipients
if not current_app.config.get('MAIL_ENABLED', False):
current_app.logger.debug(
'Skipping send_email({}) to {} (email disabled)'.format(
subject,
', '.join(recip_list)
)
)
return
if sender is None:
sender = current_app.config['MAIL_SENDER']
msg = Message(subject, sender=sender, recipients=recip_list)
msg.body = body
msg.html = html
mail.send(msg)

8
sigl/settings.py Normal file
View File

@@ -0,0 +1,8 @@
"""Sigl Default Settings.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
# Email Disabled
MAIL_ENABLED = False

27
sigl/socketio.py Normal file
View File

@@ -0,0 +1,27 @@
"""Sigl Websockets Entry Point.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask_socketio import SocketIO
socketio = SocketIO()
__all__ = ('socketio', 'init_socketio')
def init_socketio(app):
'''Initialize the Web Socket Handler'''
socketio_opts = {
'cors_allowed_origins': '*',
'logger': app.config.get('SOCKETIO_LOGGING', False),
'engineio_logger': app.config.get('ENGINEIO_LOGGING', False),
}
socketio.init_app(app, **socketio_opts)
# Notify Initialization
app.logger.debug('Web Sockets Initialized')
# Return Success
return True

5
tox.ini Normal file
View File

@@ -0,0 +1,5 @@
[flake8]
ignore = D300,D400,E201,E202,E203,E241
exclude = config docs migrations node_modules scripts
extend-ignore = E501 sigl/database/orm.py, E124 E127 E128 sigl/database/views.py
docstring-convention = google