Initial commit
This commit is contained in:
commit
4c0fdbe89f
12 changed files with 296 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
.DS_Store
|
||||
.idea
|
||||
*.log
|
||||
tmp/
|
||||
*.egg-info
|
||||
poetry.lock
|
||||
|
||||
*.py[cod]
|
||||
*.egg
|
||||
build
|
||||
htmlcov
|
0
README.rst
Normal file
0
README.rst
Normal file
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
|
@ -0,0 +1,20 @@
|
|||
[tool.poetry]
|
||||
name = "tutor"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Correl Roush <correl@gmail.com>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9"
|
||||
tornado = "^6.1"
|
||||
aiosqlite = "^0.17.0"
|
||||
click = "^8.0.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^5.2"
|
||||
black = "^21.6b0"
|
||||
mypy = "^0.910"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
7
tables.sql
Normal file
7
tables.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS `copies` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scryfallId TEXT,
|
||||
isFoil INTEGER NOT NULL DEFAULT 0,
|
||||
language TEXT,
|
||||
condition TEXT
|
||||
);
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
5
tests/test_tutor.py
Normal file
5
tests/test_tutor.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from tutor import __version__
|
||||
|
||||
|
||||
def test_version():
|
||||
assert __version__ == '0.1.0'
|
1
tutor/__init__.py
Normal file
1
tutor/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = '0.1.0'
|
101
tutor/__main__.py
Normal file
101
tutor/__main__.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import aiosqlite
|
||||
import click
|
||||
import tornado.ioloop
|
||||
import tornado.web
|
||||
|
||||
import tutor.csvimport
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Card:
|
||||
uuid: str
|
||||
name: str
|
||||
set_code: str
|
||||
|
||||
|
||||
async def search(
|
||||
db: aiosqlite.Connection,
|
||||
name: typing.Optional[str],
|
||||
) -> typing.List[Card]:
|
||||
db.row_factory = aiosqlite.Row
|
||||
constraints = []
|
||||
params = {}
|
||||
if name:
|
||||
constraints.append("cards.name LIKE :name")
|
||||
params["name"] = f"%{name}%"
|
||||
query = " ".join(
|
||||
[
|
||||
"SELECT * FROM cards",
|
||||
"WHERE" if constraints else "",
|
||||
" AND ".join(constraints),
|
||||
"LIMIT 10",
|
||||
]
|
||||
)
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Card(
|
||||
uuid=row["uuid"],
|
||||
name=row["name"],
|
||||
set_code=row["setCode"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
class SearchHandler(tornado.web.RequestHandler):
|
||||
async def get(self) -> None:
|
||||
async with aiosqlite.connect("/media/correlr/Correl/AllPrintings.sqlite") as db:
|
||||
cards = await search(db, name=self.get_argument("name", None))
|
||||
self.write(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"uuid": card.uuid,
|
||||
"name": card.name,
|
||||
"set_code": card.set_code,
|
||||
}
|
||||
for card in cards
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--database", type=click.Path(dir_okay=False), required=True)
|
||||
@click.pass_context
|
||||
def cli(ctx, database):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['database'] = database
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def server(ctx):
|
||||
app = tornado.web.Application(
|
||||
[
|
||||
(r"/", SearchHandler),
|
||||
],
|
||||
debug=True,
|
||||
)
|
||||
app.listen(8888)
|
||||
tornado.ioloop.IOLoop.current().start()
|
||||
|
||||
|
||||
@cli.command("import")
|
||||
@click.argument("filename", type=click.Path(dir_okay=False))
|
||||
@click.pass_context
|
||||
def import_cards(ctx, filename):
|
||||
cards = tornado.ioloop.IOLoop.current().run_sync(
|
||||
lambda: tutor.csvimport.load(ctx.obj, filename)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
cli()
|
0
tutor/app.py
Normal file
0
tutor/app.py
Normal file
56
tutor/csvimport.py
Normal file
56
tutor/csvimport.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
import csv
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import aiosqlite
|
||||
|
||||
import tutor.database
|
||||
import tutor.models
|
||||
|
||||
|
||||
async def load(settings: dict, filename: str) -> typing.List[tutor.models.Card]:
|
||||
"""Load cards from a CSV file.
|
||||
|
||||
Currently supports the following formats:
|
||||
- Deckbox (set name match can fail)
|
||||
- MTGStand (uses Scryfall ID)
|
||||
"""
|
||||
cards = []
|
||||
async with aiosqlite.connect(settings["database"]) as db:
|
||||
with open(filename) as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
for row in reader:
|
||||
is_foil = "Foil" in row and row["Foil"].lower() == "foil"
|
||||
quantity = int(row.get("Quantity", row.get("Count", 1)))
|
||||
if "Scryfall ID" in row:
|
||||
found = await tutor.database.search(
|
||||
db, scryfall_id=row["Scryfall ID"]
|
||||
)
|
||||
else:
|
||||
found = await tutor.database.search(
|
||||
db,
|
||||
name=row["Name"],
|
||||
set_name=row["Edition"],
|
||||
collector_number=row["Card Number"],
|
||||
foil=is_foil or None,
|
||||
)
|
||||
if not found:
|
||||
logging.warning("Could not find card for row %s", row)
|
||||
elif len(found) > 1:
|
||||
logging.warning(
|
||||
"Found %s possibilities for row %s", len(found), row
|
||||
)
|
||||
for card in found:
|
||||
logging.warning(card)
|
||||
else:
|
||||
card = tutor.models.CardCopy(
|
||||
card=found[0],
|
||||
foil=is_foil,
|
||||
language=row["Language"] or "English",
|
||||
)
|
||||
logging.info((quantity, card))
|
||||
for i in range(quantity):
|
||||
cards.append(card)
|
||||
await tutor.database.store_copy(db, card)
|
||||
await db.commit()
|
||||
return cards
|
78
tutor/database.py
Normal file
78
tutor/database.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
import typing
|
||||
import uuid
|
||||
|
||||
import aiosqlite
|
||||
|
||||
import tutor.models
|
||||
|
||||
|
||||
async def search(
|
||||
db: aiosqlite.Connection,
|
||||
name: typing.Optional[str] = None,
|
||||
collector_number: typing.Optional[str] = None,
|
||||
set_code: typing.Optional[str] = None,
|
||||
set_name: typing.Optional[str] = None,
|
||||
foil: typing.Optional[bool] = None,
|
||||
alternate_art: typing.Optional[bool] = None,
|
||||
scryfall_id: typing.Optional[str] = None,
|
||||
limit: int = 10,
|
||||
distinct: bool = True,
|
||||
) -> typing.List[tutor.models.Card]:
|
||||
db.row_factory = aiosqlite.Row
|
||||
constraints = []
|
||||
params = {}
|
||||
if name is not None:
|
||||
constraints.append("cards.name LIKE :name")
|
||||
params["name"] = name
|
||||
if collector_number is not None:
|
||||
constraints.append("cards.number LIKE :number")
|
||||
params["number"] = collector_number
|
||||
if set_code is not None:
|
||||
constraints.append("cards.setCode LIKE :set_code")
|
||||
params["set_code"] = set_code
|
||||
if set_name is not None:
|
||||
constraints.append("sets.name LIKE :set_name")
|
||||
params["set_name"] = set_name
|
||||
if foil is not None:
|
||||
constraints.append("cards.hasFoil IS :foil")
|
||||
params["foil"] = foil
|
||||
if alternate_art is not None:
|
||||
constraints.append("cards.isAlternative IS :alternative")
|
||||
params["alternative"] = alternate_art
|
||||
if scryfall_id is not None:
|
||||
constraints.append("cards.scryfallId LIKE :scryfall_id")
|
||||
params["scryfall_id"] = scryfall_id
|
||||
if distinct:
|
||||
constraints.append("(cards.side IS NULL OR cards.side IS 'a')")
|
||||
query = " ".join(
|
||||
[
|
||||
"SELECT cards.* FROM cards",
|
||||
"JOIN sets ON (cards.setCode = sets.code)",
|
||||
"WHERE" if constraints else "",
|
||||
" AND ".join(constraints),
|
||||
f"LIMIT {limit}",
|
||||
]
|
||||
)
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
tutor.models.Card(
|
||||
scryfall_id=uuid.UUID(row["scryfallId"]),
|
||||
name=row["name"],
|
||||
set_code=row["setCode"],
|
||||
collector_number=row["number"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def store_copy(db: aiosqlite.Connection, copy: tutor.models.CardCopy) -> None:
|
||||
await db.execute(
|
||||
"INSERT INTO copies (scryfallId, isFoil, condition)"
|
||||
"VALUES (:scryfall_id, :foil, :condition)",
|
||||
{
|
||||
"scryfall_id": str(copy.card.scryfall_id),
|
||||
"foil": copy.foil,
|
||||
"condition": copy.condition,
|
||||
},
|
||||
)
|
17
tutor/models.py
Normal file
17
tutor/models.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
import dataclasses
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Card:
|
||||
scryfall_id: uuid.UUID
|
||||
name: str
|
||||
set_code: str
|
||||
collector_number: str
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CardCopy:
|
||||
card: Card
|
||||
foil: bool
|
||||
language: str = "English"
|
||||
condition: typing.Optional[str] = None
|
Loading…
Add table
Reference in a new issue