diff --git a/README.md b/README.md index 2fbd1cb..92ade47 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,19 @@ Clone and install with `pipx install scry` ## use -- Draw a random card and add it to your database with `scry random` -- Get a list of cards based on a scryfall [search query](https://scryfall.com/docs/syntax) and add them to your database: - - `scry list "set:blb"` returns unique cards from the Bloomburrow set and shows stats for that list. -- Request a list of set releases with: `scry setcodes` -- Return stats for your entire database: `scry stats` -- (Optional:) Clear your database: `scry clear` +- Request a reference list of set releases with: `scry setlist` +- Get stats for a specific set: + - `scry set BLB` returns all cards from the _Bloomburrow_ set + - `scry set latest` finds the most recent release. +- Get stats for cards based on a scryfall [search query](https://scryfall.com/docs/syntax): + - `scry search id:orzhov type:land legal:modern` returns all unique Orzhov Land cards that are legal in Modern format, and shows stats for that list. +- Get help with `scry --help` + +### local database + +Scry creates a local sqlite database and adds your queried cards to it. This means you can build a larger collection of cards by executing multiple searches, and then view stats for the entire database with `scry stats` + +To clear your database (for instance, to start a fresh collection to view stats on), run `scry clear` and confirm at the prompt. ## about diff --git a/pyproject.toml b/pyproject.toml index 45ad738..ceb739c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ readme = "README.md" # defines the command and script that will be used to run [project.scripts] -scry = "scry.cli:main" +scry = "scry.main:main" [project.urls] repository = "https://github.com/thrly/scry" diff --git a/src/scry/cli.py b/src/scry/cli.py index fbbbfb3..93adb7d 100644 --- a/src/scry/cli.py +++ b/src/scry/cli.py @@ -1,11 +1,8 @@ -# add cards to db using scryfall -# query and return stats - - +import argparse from datetime import datetime -from sys import argv + +from scry.request import find_current_release from . import ( - create_table, get_random_card, insert_cards, get_card_list, @@ -13,69 +10,183 @@ get_total_cards, clear_database, set_codes, - db_connect, ) -def main(): - connection = db_connect() - try: - create_table(connection) - - if len(argv) > 1: - # TODO: swap argv for argparse - req_type = argv[1] - # use argument inputs from CLI: random (single card) or list (multiple cards) - if req_type == "random": - # optional query argument for random card, otherwise no constraint - if 1 < len(argv) > 2: - query = argv[1] - else: - query = "" - # get a single random card, based on search parameters - card = get_random_card(query) or [] - insert_cards(card, get_timestamp(), connection) - print(get_total_cards(connection), "cards currently in database.") - - elif req_type == "list": - if len(argv) > 2: - search_param = argv[2] - card_list = get_card_list(search_param) or [] - stamp = get_timestamp() - insert_cards(card_list, stamp, connection) - print(get_total_cards(connection), "cards currently in database.") - print( - f"================================================\nSTATS for '{search_param}':" - ) - print_stats(connection, stamp) - - else: - print("Lists need a query parameter (i.e. 'color:black set:BLB')") - - elif req_type == "setcodes": - for set_code in set_codes(): - if set_code[3] == "expansion" or set_code[3] == "commander": - # extract year from YYYY-MM-DD - date = datetime.fromisoformat(set_code[2]) - print( - f"{set_code[0]} : {set_code[1]}\t{set_code[4]} cards\t{date.year}" - ) - elif req_type == "clear": - clear_database() - - elif req_type == "stats": - print( - "================================================\nSTATS for ALL cards in database:" - ) - print_stats(connection) +def build_arg_parser() -> argparse.ArgumentParser: + # setup parser and sub command parsers + parser = argparse.ArgumentParser( + description="🃏 Stats for card sets from Scryfall.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + """ + Examples: + scry setlist + scry set BLB + scry set latest + scry search t:creature c:green legal:modern + """ + ), + ) + subparsers = parser.add_subparsers( + # dest="subcommand", + title="subcommands", + description="Basic scrying functions. Some require additional parameters.", + required=True, + ) + + # Define each subcommand: + # RANDOM ------------------------ + random_parser = subparsers.add_parser( + "random", + help="Draw random cards from Scryfall", + ) + random_parser.add_argument( + "-n", + "--number", + type=int, + default=1, + help="Number of random cards to draw", + ) + random_parser.set_defaults(func=handle_random) + + # SEARCH ------------------------ + # TODO: bypass this completely and just run with `scry ` + list_parser = subparsers.add_parser( + "search", help="Returns a list of cards matching search parameters" + ) + list_parser.add_argument( + "search_query", + nargs="+", + help="Arguments as Scryfall-syntax search query (e.g. 't:creature c:green')", + # TODO: is it possible to set a default value if no query arg is given? 't:land' etc. + ) + list_parser.set_defaults(func=handle_search) + + # SET ------------------------ + set_parser = subparsers.add_parser("set") + set_parser.add_argument( + "set_query", + help="Specify setcode (run `scry setlist` for reference) or 'latest'", + ) + set_parser.set_defaults(func=handle_set) + + # SETLIST ------------------------ + setlist_parser = subparsers.add_parser( + "setlist", + help="Return list of sets with code, card total, and year of release", + ) + setlist_parser.set_defaults(func=handle_setlist) + + # STATS ----------------------- + stats_parser = subparsers.add_parser( + "stats", help="Return stats for current database" + ) + # TODO: Add additional optional args to filter search query: type, colour, set, etc. + # + # stats_parser.add_argument( + # "-s", "--set", help="search database for cards matching this setcode" + # ) + # stats_parser.add_argument( + # "-ct", "--card-type", help="search database for cards matching this type" + # ) + + stats_parser.set_defaults(func=handle_stats) + + # CLEAR ------------------------ + clear_parser = subparsers.add_parser("clear", help="Clear the database") + clear_parser.set_defaults(func=handle_clear) + + return parser + + +################ +# Command handlers +# + + +def handle_random(args, db_connection): + if args.number == 1: + print("Drawing a random card from Scryfall.com...") + else: + print(f"Drawing {args.number} random cards from Scryfall.com...") + + query = "" # TODO: add optional search flag to random argparse + + # get a single random card, based on search parameters + card = get_random_card(query) or [] + insert_cards(card, get_timestamp(), db_connection) + print(get_total_cards(db_connection), "cards currently in database.") + + +def handle_search(args, db_connection): + query = " ".join(args.search_query) + print(f"Searching for cards matching: {query}") + + card_list = get_card_list(query) or [] + stamp = get_timestamp() + insert_cards(card_list, stamp, db_connection) + # print(get_total_cards(db_connection), "cards currently in database.") + print(f"{len(card_list)} cards found.") + print_stats(db_connection, stamp) + + +def handle_set(args, db_connection): + # Search for a set of cards + connection = db_connection + query_setcode = "" + if args.set_query.lower() == "latest": + # Find the moce recent release and query stats for it + current_set = find_current_release(set_codes()) + current_set_code = current_set.get("set_code") + query_setcode = str(current_set_code) + current_set_name = current_set.get("name") + print(f"Stats for {current_set_name} ({current_set_code}):") + else: + query_setcode = str(args.set_query) + print(f"Stats for {lookup_set_info("name", args.set_query.upper())}") + + query = f"set:{query_setcode} unique:prints" # unique:prints includes variations within set + + print(f" Released: {lookup_set_info("release_date", query_setcode.upper())}") + + print(f" {lookup_set_info("card_count", query_setcode.upper())} cards in set") + + card_list = get_card_list(query) or [] + stamp = get_timestamp() + + insert_cards(card_list, stamp, connection) + print_stats(connection, stamp) + + +def handle_setlist(args, db_connection): + print("All Main and Commander MTG expansion sets:\n") + setlist = set_codes() + current_set = find_current_release(setlist) + for set_info in setlist: + print( + format_set_info(set_info), + end="", + ) + if set_info == current_set: + print(" <- current release") else: - print( - "No valid search parameters. Try `scry random` or `scry list ''" - ) - except Exception as err: - print("Error in __main__: ", err) - finally: - connection.close() # finally close db connection + print() + + +def handle_stats(args, db_connection): + print("STATS for ALL cards in database:") + print(get_total_cards(db_connection), "cards in database") + print_stats(db_connection) + + +def handle_clear(args, db_connection): + # HACK: why does this only work with db_connection and args, even though neither + # are required? Same with handle_setlist... + clear_database() + + +# Helper functions: def print_stats(connection, timestamp=None): @@ -84,9 +195,18 @@ def print_stats(connection, timestamp=None): print(s) -def get_timestamp(): - return datetime.now() +def format_set_info(set_details) -> str: + date = datetime.fromisoformat(set_details["release_date"]) + return f"{set_details["set_code"]: <5} {set_details["name"]:<38} {set_details["card_count"]:>6} cards {date.year:>10}" + + +def lookup_set_info(info: str, set_code: str) -> str: + setlist = set_codes() + for s in setlist: + if set_code == s["set_code"]: + return s[info] + return "Set info not found. Check the setcode is correct. You can request name, card_count, release_date" -if __name__ == "__main__": - main() +def get_timestamp(): + return datetime.now() diff --git a/src/scry/db_insert.py b/src/scry/db_insert.py index 9fe6705..1815f7a 100644 --- a/src/scry/db_insert.py +++ b/src/scry/db_insert.py @@ -17,11 +17,6 @@ def insert_cards(cards: list, timestamp: datetime, connection) -> int: cursor.executemany(insert_query, rows) connection.commit() - if len(cards) > 1: - print(f"Added {len(cards)} cards into database.") - else: - print(f"Added '{cards[0]['name']}' into database.") - return len(cards) except Exception as err: print(f"Error occured talking to database: {err}") diff --git a/src/scry/db_queries.py b/src/scry/db_queries.py index 06a497d..279d570 100644 --- a/src/scry/db_queries.py +++ b/src/scry/db_queries.py @@ -23,7 +23,7 @@ def db_stats(connection, stamp=None) -> list: # print("timestamp_query: ", timestamp_query) total_cards = get_total_cards(connection, stamp) - stats.append(f"Total cards: {total_cards}") + # stats.append(f"Stats for {total_cards} cards") cursor = connection.cursor() @@ -32,22 +32,40 @@ def db_stats(connection, stamp=None) -> list: f"SELECT cmc, COUNT(*) as Mana FROM cards {timestamp_query} GROUP BY cmc" ) curve = cursor.fetchall() - stats.append(f"\n## MANA CURVE\n{chart_data(curve, total_cards)}") + stats.append(f"\nMANA CURVE\n{chart_data(curve, total_cards)}") + + # Colour distribution + cursor.execute( + f"""SELECT + value as color_identity, + COUNT(*) AS color_count + FROM cards, + json_each(cards.color_identity) {timestamp_query} + GROUP BY value + ORDER BY color_count DESC + """ + ) + + curve = cursor.fetchall() + coloured_results = [[scryfall_colours(id), count] for id, count in curve] + + stats.append( + f"\nCOLOUR DISTRIBUTION\n{chart_data(coloured_results, total_cards)}" + ) # Tally of card types cursor.execute( report_card_types(timestamp_query)[0], report_card_types(timestamp_query)[1] ) curve = cursor.fetchall() - stats.append(f"## CARD TYPES\n{chart_data(curve, total_cards)}") + stats.append(f"CARD TYPES\n{chart_data(curve, total_cards)}") # Prices: highest and average - stats.append("## PRICES\n") + stats.append("PRICES\n") stats.append( - f" Average Price is {report_prices(cursor,timestamp_query)[1]} EUR" + f" Average Price is {report_prices(cursor,timestamp_query)[1]} EUR\n" ) stats.append("\n".join(report_prices(cursor, timestamp_query)[0])) - stats.append("\n===========================\n") return stats @@ -129,13 +147,25 @@ def report_prices(cursor, timestamp_query: str): # available (not always), they should also be included and aaveraged, though it might # skew the hightest prices? cursor.execute( - f"SELECT name, CAST(json_extract(price,'$.eur') AS REAL) AS price FROM cards {timestamp_query} ORDER BY price DESC LIMIT 3" + f"SELECT name, CAST(json_extract(price,'$.eur') AS REAL) AS price FROM cards {timestamp_query} ORDER BY price DESC LIMIT 9" ) highest_price = cursor.fetchall() top_prices = [] top_prices.append(" Most expensive cards:") - for i, val in enumerate(highest_price): - top_prices.append(f" {i+1}. '{val[0]}' at {round(val[1],2)} EUR") + # check for duplicate cards appearing in expensive list + # this happens when variation prints are equally sought after + # do not append duplicate cards to the top + + top_price_dict = {} + for item in highest_price: + if item[0] not in top_price_dict.keys(): + top_price_dict[item[0]] = item[1] + + for card, price in top_price_dict.items(): + card_price_info = f" - {card:<35} {round(price,2):>10} EUR" + top_prices.append(card_price_info) + if len(top_prices) == 4: # stop after three top prices + 1 for heading + break cursor.execute( f"SELECT AVG(CAST(json_extract(price,'$.eur') AS REAL)) FROM cards {timestamp_query}" @@ -143,3 +173,8 @@ def report_prices(cursor, timestamp_query: str): average_price = cursor.fetchone()[0] return top_prices, round(average_price, 2) + + +def scryfall_colours(reference: str) -> str: + colour_codes = {"R": "Red", "G": "Green", "U": "Blue", "B": "Black", "W": "White"} + return colour_codes.get(reference, "Unknown") diff --git a/src/scry/main.py b/src/scry/main.py new file mode 100644 index 0000000..4f350d8 --- /dev/null +++ b/src/scry/main.py @@ -0,0 +1,35 @@ +# Main entry point for SCRY: a command-line scryfall query tool +# by thrly + + +from . import ( + create_table, + db_connect, +) +from .cli import ( + build_arg_parser, +) + + +def main(argv=None): + connection = db_connect() + + # setup argument parsing (argv for testing) + parser = build_arg_parser() + args = parser.parse_args(argv) + + try: + # setup / connect to local database + create_table(connection) + + # execute commands from cli arguments (see cli.py for handling) + args.func(args, connection) + + except Exception as err: + print("Error in __main__: ", err) + finally: + connection.close() # finally close db connection + + +if __name__ == "__main__": + main() diff --git a/src/scry/request.py b/src/scry/request.py index 72d95ac..71557f3 100644 --- a/src/scry/request.py +++ b/src/scry/request.py @@ -1,3 +1,4 @@ +from datetime import datetime import requests from time import sleep import urllib.parse @@ -8,6 +9,9 @@ # NOTE: scryfall returns an 'object : card/list` which could be used to detemine how to display/add single/lists of cards +################# +# Return a random card (optional search parameters) + def get_random_card(query: str) -> list: clean_query = urllib.parse.quote(query) @@ -15,7 +19,7 @@ def get_random_card(query: str) -> list: try: - res = requests.get(url + endpoint + clean_query, headers=headers, timeout=3) + res = requests.get(url + endpoint + clean_query, headers=headers, timeout=5) res.raise_for_status() card = res.json() @@ -39,6 +43,10 @@ def get_random_card(query: str) -> list: return [] +################# +# Return list of cards based on search query + + def get_card_list(query: str) -> list: clean_query = urllib.parse.quote(query) @@ -106,6 +114,40 @@ def show_warnings(res): print(f"WARNING [LIST REQ]: {warnings}") +###################### +# Retreive List of Set Releases + + +def check_date_past(date_to_check) -> str: + # check the release date against today's date to see if its past or future + date_to_check = datetime.date(datetime.fromisoformat(date_to_check)) + current_date = datetime.date(datetime.today()) + if current_date >= date_to_check: + return "Past" + else: + return "Future" + + +def is_current_release(date_A: datetime, date_B: datetime) -> str: + if check_date_past(date_A) == "Future" and check_date_past(date_B) == "Past": + return "*" + else: + return "-" + + +def find_current_release(setlist: list) -> dict: + for i, item in enumerate(setlist): + if i > 0: + check = is_current_release( + setlist[i - 1]["release_date"], setlist[i]["release_date"] + ) + if check == "*": + return dict(item) + + print("Current release not found") + return {} + + def set_codes() -> list: endpoint = "/sets" @@ -127,15 +169,18 @@ def set_codes() -> list: # TODO: like the card_transform, this would be better to define as a dict, rather than a list for set_info in setlist_data: - setlist.append( - [ - set_info.get("code"), - set_info.get("name"), - set_info.get("released_at"), - set_info.get("set_type"), - set_info.get("card_count"), - ] - ) + released = set_info.get("released_at") + set_type = set_info.get("set_type") + if set_type == "expansion" or set_type == "commander": + setlist.append( + { + "set_code": set_info.get("code").upper(), + "name": set_info.get("name"), + "release_date": released, + # "set_type": set_info.get("set_type"), + "card_count": set_info.get("card_count"), + } + ) return setlist else: print( diff --git a/tests/test_db.py b/tests/test_db.py index a5d19ff..945d2f9 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -5,7 +5,7 @@ from tests.sample_card import sample_card -def test_card_insert_and_get(capfd): +def test_card_insert_and_get(): connection = sqlite3.connect("./tests/tests.db") try: @@ -14,10 +14,6 @@ def test_card_insert_and_get(capfd): assert insert_cards([sample_card()], datetime.datetime.now(), connection) == 1 # this should return 1 (the single card added) - # check that insert cards writes "Added" message to stdout: - captured = capfd.readouterr() - assert captured.out == "Added 'Llanowar Elves' into database.\n" - # pull first row from test db, then check that it is Llanowar cursor = connection.cursor() cursor.execute("SELECT * FROM cards")