├── scry ├── source ├── __main__.py ├── scry_api.py ├── scry_data.py ├── scry_args.py ├── scry_help.py ├── scry_cache.py └── scry_output.py ├── LICENSE └── README.md /scry: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xdanelia/scrycall/HEAD/scry -------------------------------------------------------------------------------- /source/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | from scry_args import parse_args 5 | from scry_data import get_cards_from_query 6 | from scry_output import print_data 7 | 8 | 9 | def main(): 10 | 11 | # query: string you would type in the search bar at scryfall.com 12 | # formatting: list of format strings that determine how data is printed 13 | query, formatting = parse_args(sys.argv[1:]) 14 | 15 | if query: 16 | cards = get_cards_from_query(query) 17 | print_data(cards, formatting) 18 | 19 | return 0 20 | 21 | 22 | if __name__ == '__main__': 23 | sys.exit(main()) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 0xdanelia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /source/scry_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import urllib.error 4 | import urllib.parse 5 | import urllib.request 6 | 7 | 8 | IS_FIRST_QUERY = True 9 | 10 | 11 | def get_api_url_from_query(query): 12 | # transform the query string into a url-friendly format, and attach it to the scryfall api url 13 | api_url = 'https://api.scryfall.com/cards/search?q=' 14 | return api_url + urllib.parse.quote_plus(query) 15 | 16 | 17 | def get_api_data_from_url(url): 18 | global IS_FIRST_QUERY 19 | if not IS_FIRST_QUERY: 20 | # wait 100 milliseconds between calls to avoid spamming the api: https://scryfall.com/docs/api 21 | time.sleep(0.1) 22 | else: 23 | IS_FIRST_QUERY = False 24 | 25 | try: 26 | with urllib.request.urlopen(url) as response: 27 | data = json.load(response) 28 | except urllib.error.HTTPError as exc: 29 | if exc.code == 404: 30 | # error code 404 means the query was processed, but it returned no results 31 | # here we return None instead of raising an exception because we still want the 'bad' query to be cached 32 | data = None 33 | else: 34 | raise exc 35 | 36 | return data 37 | -------------------------------------------------------------------------------- /source/scry_data.py: -------------------------------------------------------------------------------- 1 | from scry_api import get_api_url_from_query, get_api_data_from_url 2 | from scry_cache import load_url_from_cache, write_url_to_cache 3 | from scry_cache import CACHE_FLAGS 4 | 5 | 6 | def get_cards_from_query(query): 7 | url = get_api_url_from_query(query) 8 | card_list = get_json_data_from_url(url) 9 | return card_list 10 | 11 | 12 | def get_json_data_from_url(url): 13 | json_data = load_url_from_cache(url) 14 | if json_data is None: 15 | if CACHE_FLAGS['cache-only']: 16 | return [] 17 | json_data = get_api_data_from_url(url) 18 | json_data = parse_json_data_into_list(json_data) 19 | write_url_to_cache(url, json_data) 20 | return json_data 21 | 22 | 23 | def parse_json_data_into_list(data): 24 | if data is None: 25 | return [] 26 | data_type = data.get('object') 27 | if data_type == 'list' or data_type == 'catalog': 28 | data_list = data.get('data') 29 | if data.get('has_more'): 30 | next_url = data.get('next_page') 31 | next_data = get_api_data_from_url(next_url) 32 | data_list += parse_json_data_into_list(next_data) 33 | return data_list 34 | else: 35 | return [data] 36 | -------------------------------------------------------------------------------- /source/scry_args.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from scry_cache import clean_cache, delete_cache 4 | from scry_cache import CACHE_FLAGS 5 | from scry_help import print_help, print_help_format 6 | from scry_output import PRINT_FLAGS 7 | 8 | 9 | def parse_args(args): 10 | query = None 11 | formatting = [] 12 | 13 | for arg in args: 14 | # arg is a flag 15 | flag_was_parsed = False 16 | if arg.startswith('--'): 17 | flag_was_parsed = parse_flag(arg, formatting) 18 | # arg is part of the query 19 | if not flag_was_parsed: 20 | if query is None: 21 | query = parse_string(arg) 22 | else: 23 | query = query + ' ' + parse_string(arg) 24 | 25 | # default formatting 26 | if not formatting: 27 | formatting.append('%{name} %| %{type_line} %| %{mana_cost}') 28 | 29 | return query, formatting 30 | 31 | 32 | def parse_flag(arg, formatting): 33 | if arg.startswith('--print='): 34 | # format the plain-text output 35 | value = arg[8:] 36 | if formatting: 37 | raise '"print=" flag already set' 38 | formatting.append(value) 39 | return True 40 | elif arg.startswith('--else='): 41 | # if a formatted field has no value, instead use this as the print format 42 | value = arg[7:] 43 | if not formatting: 44 | raise 'Must have a "print=" flag before using "else="' 45 | formatting.append(value) 46 | return True 47 | elif arg == '--no-dfc-parse': 48 | # turn off smart parsing for dual-faced-cards 49 | PRINT_FLAGS['dfc-smart-parse'] = False 50 | return True 51 | elif arg == '--dfc-default-front': 52 | # default to the front face of a dfc 53 | if PRINT_FLAGS['dfc-default-face'] is not None: 54 | raise 'dfc default face already set' 55 | PRINT_FLAGS['dfc-default-face'] = 0 56 | return True 57 | elif arg == '--dfc-default-back': 58 | # default to the back face of a dfc 59 | if PRINT_FLAGS['dfc-default-face'] is not None: 60 | raise 'dfc default face already set' 61 | PRINT_FLAGS['dfc-default-face'] = 1 62 | return True 63 | elif arg == '--cache-only': 64 | # do not query the api, only look at the cache 65 | CACHE_FLAGS['cache-only'] = True 66 | return True 67 | elif arg == '--ignore-cache': 68 | # do not look at the cache, query the api regardless 69 | CACHE_FLAGS['ignore-cache'] = True 70 | return True 71 | elif arg == '--do-not-cache': 72 | # do not save the query results to the cache 73 | CACHE_FLAGS['do-not-cache'] = True 74 | return True 75 | elif arg == '--clean-cache': 76 | clean_cache() 77 | return True 78 | elif arg == '--delete-cache': 79 | delete_cache() 80 | return True 81 | elif arg == '--help': 82 | print_help() 83 | sys.exit(0) 84 | elif arg == '--help-format': 85 | print_help_format() 86 | sys.exit(0) 87 | return False 88 | 89 | 90 | def parse_string(arg): 91 | # backticks are replaced with single quotes 92 | # otherwise the text is used as-is 93 | result = arg.replace("`", "'") 94 | return result 95 | -------------------------------------------------------------------------------- /source/scry_help.py: -------------------------------------------------------------------------------- 1 | def print_help(): 2 | #####('--------------------------------------------------------------------------------') 80 3 | print('') 4 | print('Special flags begin with "--" and can appear anywhere in the input.') 5 | print('Everything else will be treated as part of the query string.') 6 | print('Query syntax reference: https://scryfall.com/docs/syntax') 7 | print('') 8 | print('--print="CUSTOM OUTPUT TEXT"') 9 | print(' Set the format of the output. Use %X to print certain card properties.') 10 | print(' Default is "%{name} %| %{type_line} %| %{mana_cost}".') 11 | print(' Use --help-format for more information.') 12 | print('') 13 | print('--else="CUSTOM OUTPUT TEXT"') 14 | print(' When a property in --print="" is not available, then instead try to') 15 | print(' print the contents in the --else="" flag. Multiple --else="" flags can') 16 | print(' be used in case one of them also contains a property that is unavailable.') 17 | print('') 18 | print('--no-dfc-parse') 19 | print(' Dual-faced-card objects are formatted differently than regular cards. If a') 20 | print(' property is not available on the top-level DFC object, then Scrycall will') 21 | print(' automatically look for that property on the front and back faces.') 22 | print(' Setting this flag will disable the automatic parsing of the card faces.') 23 | print('') 24 | print('--dfc-default-front') 25 | print('--dfc-default-back') 26 | print(' Select which card face to work with when handling dual-faced-cards.') 27 | print('') 28 | print('--cache-only') 29 | print(' Query your local cache only. Do not query the api even if cache is stale.') 30 | print('') 31 | print('--ignore-cache') 32 | print(' Query the api only. Do not query cache even if api can not be reached.') 33 | print('') 34 | print('--do-not-cache') 35 | print(' Do not write new api data to your local cache.') 36 | print('') 37 | print('--clean-cache') 38 | print(' Delete any stale data from the local cache.') 39 | print('') 40 | print('--delete-cache') 41 | print(' Delete everything from the local cache.') 42 | print('') 43 | #####('--------------------------------------------------------------------------------') 80 44 | 45 | 46 | def print_help_format(): 47 | #####('--------------------------------------------------------------------------------') 80 48 | print('') 49 | print('The --print="" string is what this program will print after finishing the query.') 50 | print('Use %X to print card properties. Everything else will be printed as written.') 51 | print('These properties come from the JSON card objects returned by the api.') 52 | print('The --else="" string will be printed instead if any property is unavailable.') 53 | print('') 54 | print('%n name') 55 | print('%m mana_cost') 56 | print('%c cmc (converted mana cost)') 57 | print('%y type_line') 58 | print('%p power') 59 | print('%t toughness') 60 | print('%l loyalty') 61 | print('%o oracle_text') 62 | print('%f flavor_text') 63 | print('%% this will print a literal % instead of interpreting a special character') 64 | print('%| this will separate output into nicely spaced columns') 65 | print('') 66 | print('To reference a property of the JSON card object that is not listed above,') 67 | print('put the name of that property inside "%{}". To traverse multiple objects,') 68 | print('separate their names with "." within the brackets.') 69 | print('$ scry "lightning bolt" --print="%{legalities.modern}"') 70 | print('') 71 | print('To print all available property names, use "?" in the brackets.') 72 | print('$ scry lightning bolt --print="%{prices.?}"') 73 | print('') 74 | print('To iterate every property of a JSON object, use "*" in the brackets.') 75 | print('This may print multiple lines for each card returned by the query.') 76 | print('$ scry lightning bolt --print="%{image_uris.*}"') 77 | print('') 78 | print('You can print the name of the previous property using "^" within the brackets.') 79 | print('This can be useful when combined with iterating.') 80 | print('$ scry lightning bolt --print="%{prices.*.^} %| %{prices.*}"') 81 | print('') 82 | print('Some properties are web addresses for an api call to another object. You can') 83 | print('call the api and continue parsing by using a "/". This will always return a') 84 | print('list, which you can iterate through with "*" or with a specific index.') 85 | print('$ scry lightning bolt --print="%{set_uri./.*.name}"') 86 | print('') 87 | #####('--------------------------------------------------------------------------------') 80 88 | -------------------------------------------------------------------------------- /source/scry_cache.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import os 4 | import re 5 | import time 6 | 7 | 8 | CACHE_DIR = os.path.expanduser('~') + '/.cache/scrycall/' 9 | CACHE_DIR_URL = CACHE_DIR + 'url/' 10 | 11 | CACHE_FLAGS = { 12 | 'cache-only': False, 13 | 'ignore-cache': False, 14 | 'do-not-cache': False, 15 | } 16 | # 24 hours == 86400 seconds 17 | CACHE_EXPIRATION = 86400 18 | 19 | 20 | def write_url_to_cache(url, data_list): 21 | if CACHE_FLAGS['do-not-cache']: 22 | return 23 | 24 | path = CACHE_DIR_URL + get_url_cache_name(url) 25 | cached_data_files = [] 26 | for data in data_list: 27 | # save each data object in its own file, then save a list of those filenames 28 | write_json_to_cache(data) 29 | cached_data_files.append(get_cache_path_from_object(data)) 30 | url_data_to_cache = {'url': url, 'files': cached_data_files} 31 | _write_to_cache(path, url_data_to_cache) 32 | 33 | 34 | def write_json_to_cache(data): 35 | path = CACHE_DIR + get_cache_path_from_object(data) 36 | _write_to_cache(path, data) 37 | 38 | 39 | def _write_to_cache(path, data): 40 | dir_path = os.path.split(path)[0] 41 | if not os.path.isdir(dir_path): 42 | os.makedirs(dir_path) 43 | with open(path, 'w') as cachefile: 44 | json.dump(data, cachefile, indent=4) 45 | 46 | 47 | def load_url_from_cache(url): 48 | if CACHE_FLAGS['ignore-cache']: 49 | return None 50 | 51 | path = CACHE_DIR_URL + get_url_cache_name(url) 52 | 53 | if not os.path.isfile(path): 54 | return None 55 | 56 | # files older than the expiration time hours are considered stale and are not loaded 57 | last_modified_time = os.path.getmtime(path) 58 | now = time.time() 59 | if now > last_modified_time + CACHE_EXPIRATION: 60 | return None 61 | 62 | # url caches contain a list of filenames, where each file contains cached JSON data 63 | cached_data = _load_from_cache(path) 64 | if cached_data is None: 65 | return None 66 | cache_filenames = cached_data.get('files') 67 | if cache_filenames is None: 68 | return None 69 | data = [] 70 | for filename in cache_filenames: 71 | data.append(load_json_from_cache(filename)) 72 | return data 73 | 74 | 75 | def load_json_from_cache(filename): 76 | path = CACHE_DIR + filename 77 | return _load_from_cache(path) 78 | 79 | 80 | def _load_from_cache(path): 81 | try: 82 | with open(path, 'r') as cachefile: 83 | return json.load(cachefile) 84 | except: 85 | return None 86 | 87 | 88 | def get_url_cache_name(url): 89 | # to avoid conflicts after special characters are removed, a hash of the original url is added 90 | hashed_url = hashlib.md5(url.encode()).hexdigest() 91 | trimmed_url = url.replace('https://api.scryfall.com/', '') 92 | trimmed_url = remove_special_characters(trimmed_url) 93 | 94 | # filenames are limited to 255 characters in many systems 95 | # md5 hashes are 32 characters long 96 | # limit the url portion of the filename to 220 characters, just to be safe 97 | if len(trimmed_url) > 220: 98 | trimmed_url = trimmed_url[:220] 99 | 100 | return f'{trimmed_url}_{hashed_url}' 101 | 102 | 103 | def get_cache_path_from_object(obj): 104 | # organize cache by object type, and construct a unique filename for each object type 105 | # see: "TYPES & METHODS" https://scryfall.com/docs/api/ 106 | obj_name = '' 107 | obj_id = '' 108 | obj_type = obj.get('object', 'None') 109 | 110 | if obj_type == 'card' or obj_type == 'set': 111 | obj_name = obj.get('name', '') 112 | obj_id = obj.get('id', '') 113 | 114 | elif obj_type == 'ruling': 115 | obj_name = hashlib.md5(obj.get('comment', '').encode()).hexdigest() 116 | obj_id = obj.get('oracle_id', '') 117 | 118 | obj_name = remove_special_characters(obj_name) 119 | 120 | return f'{obj_type}/{obj_name}_{obj_id}' 121 | 122 | 123 | def remove_special_characters(text): 124 | # to avoid potential issues with filenames, only use letters, numbers, '_', and '-' 125 | text = text.replace(' ', '_') 126 | text = re.sub('[^a-zA-Z0-9_]', '-', text) 127 | return text 128 | 129 | 130 | def delete_cache(): 131 | _remove_expired_files_from_cache(0) 132 | 133 | 134 | def clean_cache(): 135 | _remove_expired_files_from_cache(CACHE_EXPIRATION) 136 | 137 | 138 | def _remove_expired_files_from_cache(expire_time): 139 | # delete files from cache that are older than expire_time (in seconds) 140 | now = time.time() 141 | if os.path.isdir(CACHE_DIR): 142 | for path, dirs, files in os.walk(CACHE_DIR): 143 | for filename in files: 144 | file_path = os.path.join(path, filename) 145 | if now > os.path.getmtime(file_path) + expire_time: 146 | os.remove(file_path) 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scrycall 2 | A command line tool for querying the scryfall.com API for Magic cards. 3 | 4 | Scrycall makes it easy to search for MTG cards from the command line. It prints the card information of your choice to the terminal, allowing for easy integration with other command line tools using pipes. Scrycall uses https://scryfall.com/ to query for cards which are returned as JSON objects. You can parse the JSON using special format parameters (see below) to print any information you want about the cards you query. 5 | 6 | Scrycall also stores the JSON data in a local cache at `~/.cache/scrycall/` to quickly access for repeated queries. Anything in the cache older than 24 hours is considered stale, and will automatically be replaced with fresh data from the api. 7 | 8 | 9 | ## How to run Scrycall 10 | You can download the project using the command `git clone https://github.com/0xdanelia/scrycall` 11 | 12 | The project comes with an executable zip file `scry` which can be run like any other Python script. 13 | ``` 14 | $ python scry [ARGS] 15 | ``` 16 | On Linux, this file can be copied to a location in your `$PATH` and then called like any other program. 17 | ``` 18 | $ scry [ARGS] 19 | ``` 20 | 21 | ## How to build Scrycall 22 | The project comes with a `build.py` script which creates an executable zip file of the source code. 23 | ``` 24 | $ python build.py 25 | ``` 26 | 27 | 28 | ## How to query with Scrycall 29 | 30 | First familiarize yourself with the Scryfall search syntax at https://scryfall.com/docs/syntax 31 | 32 | Then simply run Scrycall using your plain text search query as the arguments. The below examples are from a `bash` shell. Exact formatting may vary slightly between shells. You can use a backtick `` ` `` in place of an apostrophe when making your query as well. 33 | ``` 34 | $ scry venser t:creature 35 | Venser, Shaper Savant Legendary Creature — Human Wizard {2}{U}{U} 36 | Venser's Sliver Artifact Creature — Sliver {5} 37 | ``` 38 | ``` 39 | $ scry 'Urza`s rage' 40 | Urza's Rage Instant {2}{R} 41 | ``` 42 | ``` 43 | $ scry 'o:"counter target noncreature spell unless"' 44 | Concerted Defense Instant {U} 45 | Decisive Denial Instant {G}{U} 46 | Disciple of the Ring Creature — Human Wizard {3}{U}{U} 47 | Izzet Charm Instant {U}{R} 48 | Mage's Attendant Creature — Cat Rogue {2}{W} 49 | Spell Pierce Instant {U} 50 | Stubborn Denial Instant {U} 51 | ``` 52 | 53 | You can also pipe the output into another program. For example, use Scrycall to get the url of a card image, then pipe into wget to download and save the image. 54 | ``` 55 | $ scry '!"time walk"' set:alpha --print="%{image_uris.large}" | xargs wget -O "time_walk.jpg" 56 | ``` 57 | The Scryfall.com developers request that you add a delay of 50-100 milliseconds when making multiple rapid calls to the api. Scrycall automatically adds this delay between multiple calls within the program, but you are on your own when making calls elsewhere. 58 | 59 | ## How to format output 60 | 61 | You can use the flag `--print=` to construct a format string to print information about the cards. The contents of this format string will be printed for each card. Within the format string `%` is a special character used to indicate certain card attributes based on the JSON card objects. 62 | ``` 63 | %n name 64 | %m mana_cost 65 | %c cmc (converted mana cost) 66 | %y type_line 67 | %p power 68 | %t toughness 69 | %l loyalty 70 | %o oracle_text 71 | %f flavor_text 72 | %% this will print a literal % instead of interpreting a special character 73 | %| this will separate output into nicely spaced columns 74 | ``` 75 | ``` 76 | $ scry counterspell --print='Name: (%n) - Cost: [%m] - Text: "%o"' 77 | Name: (Counterspell) - Cost: [{U}{U}] - Text: "Counter target spell." 78 | ``` 79 | 80 | You can also parse the raw JSON yourself by putting the attribute names inside `%{}` and using `.` to separate multiple attributes. 81 | ``` 82 | $ scry lightning bolt --print="%{legalities.modern}" 83 | legal 84 | ``` 85 | 86 | To print all available property names as a list, use `?`. This can be helpful while constructing your format string. 87 | ``` 88 | $ scry lightning bolt --print="%{prices.?}" 89 | ['usd', 'usd_foil', 'usd_etched', 'eur', 'eur_foil', 'tix'] 90 | ``` 91 | ``` 92 | $ scry lightning bolt --print="Price: $%{prices.usd}" 93 | Price: $3.61 94 | ``` 95 | 96 | To access a specific element of a list, you can reference its index as a number. Indexes begin at 0. 97 | ``` 98 | $ scry '"serra angel"' --print="%{keywords}" 99 | ['Flying', 'Vigilance'] 100 | ``` 101 | ``` 102 | $ scry '"serra angel"' --print="%{keywords.0}" 103 | Flying 104 | ``` 105 | 106 | To iterate every property of a json object, use `*`. This may print multiple lines for each card. 107 | ``` 108 | $ scry '"serra angel"' --print="%{keywords.*}" 109 | Flying 110 | Vigilance 111 | ``` 112 | 113 | You can print the name of the previous property using `^`. This can be useful when combined with iterating. 114 | ``` 115 | $ scry scalding tarn --print="%{prices.*.^} %| %{prices.*}" 116 | usd 26.90 117 | usd_foil 31.71 118 | eur 24.91 119 | eur_foil 37.99 120 | tix 6.78 121 | ``` 122 | 123 | Some properties are urls for an api call to another object. You can call the api and continue parsing by using a `/`. This will always return a list, which you can iterate through with `*` or with a specific index. 124 | ``` 125 | $ scry mox lotus --print="%{set_uri}" 126 | https://api.scryfall.com/sets/4c8bc76a-05a5-43db-aaf0-34deb347b871 127 | ``` 128 | ``` 129 | $ scry mox lotus --print="The set %{set_uri./.*.name} was released %{set_uri./.*.released_at}" 130 | The set Unhinged was released 2004-11-19 131 | ``` 132 | 133 | If you try to print a property that does not exist for a card, instead nothing will be printed for that card. 134 | You can add the flag `--else=` to print something else instead. This flag takes a format string just like `--print=`. You can chain together any number of `--else=` flags. 135 | ``` 136 | $ scry venser --print="%n %| Loyalty: %| <%l>" --else="%n %| Power: %| [%p]" 137 | Venser, Shaper Savant Power: [2] 138 | Venser's Sliver Power: [3] 139 | Venser, the Sojourner Loyalty: <3> 140 | ``` 141 | 142 | For dual-faced-cards, the top-level `card` object is formatted differently than a normal card. Some properties look the same, but some are either shaped differently or missing altogether. 143 | You can access each face of a DFC via the `card_faces` property. This is a list of `card_face` objects which look much more like regular `card` objects. The first element of the list is the front face, and the second element is the back face. 144 | ``` 145 | $ scry 'huntmaster of the fells' --print='%{name} %{type_line}' 146 | Huntmaster of the Fells // Ravager of the Fells Creature — Human Werewolf // Creature — Werewolf 147 | ``` 148 | ``` 149 | $ scry 'huntmaster of the fells' --print='%{card_faces.*.name} %| %{card_faces.*.type_line}' 150 | Huntmaster of the Fells Creature — Human Werewolf 151 | Ravager of the Fells Creature — Werewolf 152 | ``` 153 | 154 | When parsing a DFC, if a property is not found on the top-level object then Scrycall will automatically check the card faces for that property. You can disable this feature with the flag `--no-dfc-parse` to treat this scenario like any other card with a missing property. 155 | ``` 156 | $ scry 'mirror-breaker' --print='%n %| %p/%t' 157 | Fable of the Mirror-Breaker // Reflection of Kiki-Jiki 2/2 158 | Kiki-Jiki, Mirror Breaker 2/2 159 | ``` 160 | ``` 161 | $ scry 'mirror-breaker' --print='%n %| %p/%t' --no-dfc-parse 162 | Kiki-Jiki, Mirror Breaker 2/2 163 | ``` 164 | You can also specify which face of the card you want to work with for DFCs with the `--dfc-default-front` or `--dfc-default-back` flags. 165 | ``` 166 | $ scry 'jace, vryn`s prodigy' --print='%n' 167 | Jace, Vryn's Prodigy // Jace, Telepath Unbound 168 | ``` 169 | ``` 170 | $ scry 'jace, vryn`s prodigy' --print='%n' --dfc-default-front 171 | Jace, Vryn's Prodigy 172 | ``` 173 | ``` 174 | $ scry 'jace, vryn`s prodigy' --print='%n' --dfc-default-back 175 | Jace, Telepath Unbound 176 | ``` 177 | 178 | ## Other optional flags 179 | ``` 180 | --cache-only 181 | Query your local cache only. Do not query the api even if cache is stale. 182 | 183 | --ignore-cache 184 | Query the api only. Do not query cache even if api can not be reached. 185 | 186 | --do-not-cache 187 | Do not write new api data to your local cache. 188 | 189 | --clean-cache 190 | Delete any stale data from the local cache. 191 | 192 | --delete-cache 193 | Delete everything from the local cache. 194 | 195 | --help 196 | Display some help, like you see here. 197 | 198 | --help-format 199 | Display some help for formatting your --print="" and --else="" flags. 200 | ``` 201 | -------------------------------------------------------------------------------- /source/scry_output.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from scry_data import get_json_data_from_url 4 | 5 | 6 | PRINT_FLAGS = { 7 | 'dfc-default-face': None, 8 | 'dfc-smart-parse': True, 9 | } 10 | 11 | # shortcuts for printing card attributes in the format string 12 | ATTR_CODES = { 13 | '%n': '%{name}', 14 | '%m': '%{mana_cost}', 15 | '%c': '%{cmc}', 16 | '%y': '%{type_line}', 17 | '%p': '%{power}', 18 | '%t': '%{toughness}', 19 | '%l': '%{loyalty}', 20 | '%o': '%{oracle_text}', 21 | '%f': '%{flavor_text}', 22 | } 23 | 24 | 25 | def print_data(data_list, format_list): 26 | flattened_format_list = ' '.join(format_list) 27 | # to handle multiple percent characters next to one another, replace '%%' with a unique placeholder first 28 | while True: 29 | percent_placeholder = '[PERCENT_' + str(time.time()) + ']' 30 | if percent_placeholder not in flattened_format_list: 31 | break 32 | 33 | # rather than split the format_string into separate columns now, do it after the data is substituted 34 | while True: 35 | column_placeholder = '[COLUMN_' + str(time.time()) + ']' 36 | if column_placeholder not in flattened_format_list: 37 | break 38 | 39 | num_columns = None 40 | for i in range(len(format_list)): 41 | format_list[i] = format_list[i].replace('%%', percent_placeholder) 42 | format_list[i] = format_list[i].replace('%|', column_placeholder) 43 | # perform a check to make sure each format string contains the same number of columns 44 | if num_columns is None: 45 | num_columns = format_list[i].count(column_placeholder) + 1 46 | else: 47 | if num_columns != format_list[i].count(column_placeholder) + 1: 48 | raise 'Each "print=" and "else=" string must contain the same number of "%|" column separators' 49 | 50 | print_lines = [] 51 | for data in data_list: 52 | # parse a specific DFC face if specified 53 | if PRINT_FLAGS['dfc-default-face'] is not None and data.get('card_faces') is not None: 54 | data = data.get('card_faces')[PRINT_FLAGS['dfc-default-face']] 55 | # populate the format string with attributes from the data 56 | # whenever a format string cannot be fully populated, try the next 'else' string 57 | formats_to_attempt = format_list 58 | while formats_to_attempt: 59 | results = get_print_lines_from_data(data, formats_to_attempt[0], percent_placeholder, column_placeholder) 60 | if results: 61 | break 62 | formats_to_attempt = formats_to_attempt[1:] 63 | print_lines += results 64 | 65 | if not print_lines: 66 | return 67 | 68 | # at this point, print_lines is a 2D list of rows and columns 69 | column_widths = [0] * num_columns 70 | # cycle through the data to find out how wide each column needs to be 71 | for row in print_lines: 72 | for i in range(num_columns): 73 | column_widths[i] = max(column_widths[i], len(row[i])) 74 | 75 | # pad each column with whitespace and concat them together to print a row 76 | for row in print_lines: 77 | padded_row = '' 78 | for i in range(num_columns): 79 | padded_column = row[i].ljust(column_widths[i]) 80 | padded_row += padded_column 81 | print(padded_row.rstrip()) 82 | 83 | 84 | def get_print_lines_from_data(data, format_string, percent_placeholder, column_placeholder): 85 | print_line = format_string 86 | 87 | print_lines = substitute_attributes_for_values(print_line, data) 88 | if not print_lines: 89 | return [] 90 | 91 | # substitute the percent placeholder in each print_line 92 | for i in range(len(print_lines)): 93 | print_lines[i] = print_lines[i].replace(percent_placeholder, '%') 94 | 95 | # turn each print_line into a list, where each element is one column to be printed 96 | column_lines = [] 97 | for line in print_lines: 98 | line_columns = line.split(column_placeholder) 99 | # line_columns is currently one row divided into columns 100 | # we want each newline within those columns to become its own row 101 | newline_rows_by_columns = preserve_newlines_in_columns(line_columns) 102 | for newline_row in newline_rows_by_columns: 103 | column_lines.append(newline_row) 104 | 105 | return column_lines 106 | 107 | 108 | def substitute_attributes_for_values(print_line, data): 109 | # substitute the attribute shortcuts '%x' with their long form '%{attribute}' strings 110 | for attr_code in ATTR_CODES: 111 | print_line = print_line.replace(attr_code, ATTR_CODES[attr_code]) 112 | 113 | print_lines = [] 114 | 115 | while True: 116 | # replace any '%{attribute}' strings with the correct value from the input data 117 | attribute_name = get_next_attribute_name(print_line) 118 | if attribute_name is None: 119 | # get_next_attribute_name() returns None when there are no more attributes to substitute 120 | break 121 | 122 | if '*' in attribute_name: 123 | # '*' in an attribute can generate multiple lines 124 | iterated_print_lines = iterate_attributes_in_print_line(print_line, attribute_name, data) 125 | for line in iterated_print_lines: 126 | print_lines += substitute_attributes_for_values(line, data) 127 | return print_lines 128 | 129 | else: 130 | attribute_value = get_attribute_value(attribute_name, data) 131 | # if any attribute value is None, do not print anything on this line 132 | if attribute_value is None: 133 | return [] 134 | print_line = print_line.replace('%{' + attribute_name + '}', str(attribute_value)) 135 | 136 | # even if only one line is printed, return it in a list so that it can be iterated in earlier functions 137 | print_lines.append(print_line) 138 | 139 | return print_lines 140 | 141 | 142 | def get_next_attribute_name(line): 143 | # attributes are formatted like '%{attribute_name}' 144 | start_idx = line.find('%{') 145 | if start_idx == -1 or start_idx == len(line) - 2: 146 | return None 147 | end_idx = line.find('}', start_idx) 148 | if end_idx == -1: 149 | return None 150 | attr = line[start_idx + 2: end_idx] 151 | return attr 152 | 153 | 154 | def get_attribute_value(attribute_name, data): 155 | # nested attributes can be chained together like '%{top.middle.bottom}' 156 | nested_attributes = attribute_name.split('.') 157 | attr_value = data 158 | prev_attr_name = None 159 | for attr in nested_attributes: 160 | if attr == '?': 161 | # return a list of the currently valid attribute names 162 | attr_value = get_list_of_available_attribute_names(attr_value) 163 | elif attr == '^': 164 | # return the name of the previous attribute 165 | attr_value = prev_attr_name 166 | elif attr == '/': 167 | # if the previous value is a scryfall api endpoint, return its data 168 | if isinstance(attr_value, str) and attr_value.startswith('https://api.scryfall.com/'): 169 | attr_value = get_json_data_from_url(attr_value) 170 | else: 171 | return None 172 | else: 173 | attr_value = get_value_from_json_object(attr, attr_value) 174 | if attr_value is None: 175 | # if an attribute cannot be found on a DFC, try looking at the individual faces 176 | if PRINT_FLAGS['dfc-smart-parse'] and data.get('card_faces') is not None: 177 | attr_value = get_attribute_value(attribute_name, data.get('card_faces')[0]) 178 | if attr_value is None: 179 | attr_value = get_attribute_value(attribute_name, data.get('card_faces')[1]) 180 | return attr_value 181 | return None 182 | prev_attr_name = attr 183 | return attr_value 184 | 185 | 186 | def get_list_of_available_attribute_names(data): 187 | if isinstance(data, dict): 188 | return list(data.keys()) 189 | elif isinstance(data, list): 190 | return list(range(len(data))) 191 | else: 192 | return list(range(len(str(data)))) 193 | 194 | 195 | def get_value_from_json_object(attr, data): 196 | if isinstance(data, dict): 197 | return data.get(attr) 198 | elif attr.isdigit(): 199 | # if the given data is not a dictionary: treat the data as iterable, and treat the attribute as the index 200 | if isinstance(data, list): 201 | iterable_data = data 202 | else: 203 | iterable_data = str(data) 204 | idx = int(attr) 205 | if 0 <= idx < len(iterable_data): 206 | return iterable_data[idx] 207 | return None 208 | 209 | 210 | def iterate_attributes_in_print_line(print_line, attribute_name, data): 211 | # for each possible value that '*' produces for a given attribute, 212 | # create a new print_line, replacing the '*' with each possible individual value. 213 | 214 | if attribute_name.startswith('*'): 215 | star_idx = -1 216 | sub_attr_value = data 217 | attr_to_replace = '*' 218 | else: 219 | star_idx = attribute_name.find('.*') 220 | sub_attr_name = attribute_name[:star_idx] 221 | sub_attr_value = get_attribute_value(sub_attr_name, data) 222 | attr_to_replace = sub_attr_name + '.*' 223 | 224 | iterated_lines = [] 225 | values_to_iterate = get_list_of_available_attribute_names(sub_attr_value) 226 | 227 | for iterated_value in values_to_iterate: 228 | new_sub_attr_name = attr_to_replace.replace('*', str(iterated_value)) 229 | # if the print_line contains duplicate sub-attributes, all will be replaced here 230 | new_print_line = print_line.replace('%{' + attr_to_replace, '%{' + new_sub_attr_name) 231 | 232 | if '*' in attribute_name[star_idx + 2:]: 233 | # multiple stars in a single attribute are handled recursively 234 | new_attribute_name = attribute_name.replace(attr_to_replace, new_sub_attr_name, 1) 235 | iterated_lines += iterate_attributes_in_print_line(new_print_line, new_attribute_name, data) 236 | else: 237 | iterated_lines.append(new_print_line) 238 | 239 | return iterated_lines 240 | 241 | 242 | def preserve_newlines_in_columns(cols): 243 | # given a list where each element is a single column, 244 | # return a 2D list where each column is preserved, but each newline gets its own row 245 | num_cols = len(cols) 246 | output_cols = [] 247 | for i in range(num_cols): 248 | output_cols.append([]) 249 | max_rows = 0 250 | 251 | for i in range(num_cols): 252 | split_column = cols[i].split('\n') 253 | max_rows = max(max_rows, len(split_column)) 254 | for col_row in split_column: 255 | output_cols[i].append(col_row) 256 | 257 | # insert empty strings into the shorter columns so that the output is consistent 258 | for i in range(num_cols): 259 | while len(output_cols[i]) < max_rows: 260 | output_cols[i].append('') 261 | 262 | output_rows = [] 263 | # earlier functions expect each element of the output to be a row, rather than a column 264 | for i in range(max_rows): 265 | row = [] 266 | for col in output_cols: 267 | row.append(col[i]) 268 | output_rows.append(row) 269 | 270 | return output_rows 271 | --------------------------------------------------------------------------------