├── .gitignore ├── README.md ├── db_fiddle_public_urls.md ├── img └── demo2.gif ├── leetcode_sql_unlocked ├── __init__.py ├── leetcode_sql_unlocked.py └── src │ ├── __init__.py │ ├── driver.py │ ├── exc_thread.py │ ├── help_menu.py │ ├── leetcode.py │ ├── log.py │ ├── questions.py │ └── web_handler.py ├── requirements.txt ├── setup.py └── test ├── __init__.py ├── test_leetcode_options.py └── test_leetcode_questions.py /.gitignore: -------------------------------------------------------------------------------- 1 | tests_private/ 2 | **/logs*/ 3 | **/drivers/ 4 | *config.py 5 | *.swp 6 | *~ 7 | .DS_STORE 8 | __pycache__/ 9 | *.pyc 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Update 11/14/2021**: I just tried this program for the first time in months and it looks like `leetcode.jp` no longer hosts any LeetCode questions. I guess the good times had to come to an end. You can still access old db-fiddle URL's [here](db_fiddle_public_urls.md), but the problem statements are gone. 2 | 3 | Through the command line, the user can easily access all ~125 LeetCode SQL/Database questions, and automatically generate the tables in db-fiddle.com, a SQL database playground environment. 4 | 5 | 6 |  7 | 8 | ## Features 9 | * Auto-generate tables from each SQL/Database LeetCode problem into db-fiddle so all your queries can be tested 10 | * Past db-fiddles are saved, so progress is never lost and the user can continue right where they left off 11 | * Convenient LeetCode question navigation, by level or by number 12 | * Solutions tab easily opened 13 | 14 | ## Getting Started 15 | 1. Requires Google Chrome which can be downloaded from https://www.google.com/chrome/browser/desktop/index.html. 16 | 2. Python 3.6+. If you don't already have Python 3 installed, visit https://www.python.org/downloads/. 17 | 3. The only dependency is selenium. Either install using `pip install selenium` or use the requirements.txt file included in the repo: `pip install -r requirements.txt`. 18 | 4. Run `setup.py` to create a config file with default settings. 19 | 5. And you're all set. To start the program run `leetcode_sql_unlocked/leetcode_sql_unlocked.py` and follow the onscreen prompt. 20 | 21 | ## Command Line Options 22 | `(h)elp`: Show this help menu. 23 | 24 | `(n)ext [LEVEL]`: Select next problem. Optionally, go next by level [(e)asy, (m)edium, (h)ard], default ignores levels. Ex: 'n' to go to next problem, 'ne' to go to next easy problem. 25 | 26 | `(q)uestion NUMBER`: Select problem by question number. Ex: 'q 183' or simply '183' to go to question 183. 27 | 28 | `(s)olution`: Open solution of current problem in new chrome tab. If solution is not found, will do a google search in a new window 29 | 30 | `(d)isplay [LEVEL] [# TO DISPLAY]`: Displays list of problems. Optionally, can display by level [(e)asy, (m)edium, (h)ard]. Furthermore, can optionally choose how many problems to display, default is 15. Ex: 'd' is to display next 15 problems of all levels, 'd e 30' is to display the next 30 easy problems. 31 | 32 | `(l)oad ON/OFF`: Pre-load additional questions in the background for faster future question access. Ex: 'l on' is to turn load on, and 'l off' is to turn load off" 33 | 34 | `(e)xit`: Exit Program 35 | 36 | ## Config 37 | These settings can be configured within *leetcode_sql_unlocked/src/config.py*: 38 | 1. db-fiddle settings 39 | * `db_engine`: User has the following databases to choose from: MYSQL_8, POSTGRES_12, and SQLITE_3_3. Default is `MYSQL_8`. 40 | * `save_before_closing`: If `True`, before going to the next question or exiting, will save the current fiddle automatically. Default is `False`, meaning the user must manually click save if they want the changes they made to persist beyond the current session. 41 | * Note that the fiddle is always saved when first created- this setting is for all proceeding saves. 42 | * `check_new_save_versions`: If `True`, will check for any newer versions of an existing db-fiddle. Default is `False`. This setting should only be switched to True if user is planning to make changes to their db-fiddles outside of this program. 43 | 44 | 2. forking 45 | * `is_fork_public_url`: If `True`, will try to create a new db-fiddle by forking the public db fiddle url that is provided in db_fiddle_public_urls.md. If `False`, will create a brand new fiddle from scratch. The default is True. 46 | * Most likely, this should be kept as True as forking an existing fiddle is faster than creating a new one. The exception might be if the user wants to create a db-fiddle with a different config than the one provided in the public url, such as using Postgres rather than MySQL. 47 | 48 | 3. preloading 49 | * `is_preload`: If `True`, preloading will be turned on and if `False` it will be turned off. True by default. 50 | * This can be toggled within the program itself by using 'l on' and 'l off'. 51 | * `n_to_preload`: For each question selected, the number of succeeding questions to preload in the background. The default is `1`. 52 | * `n_same_level_to_preload`: For each question selected, the number of succeeding questions of the SAME LEVEL to preload in the background. The default is `1`. 53 | 54 | ##### Additional notes on pre-loading 55 | Preloading refers to creating additional db-fiddles in a background/headless web driver. The questions that will be preloaded are those that are next in line numerically from the question the user is currently on. 56 | 57 | Preloading should be useful for most users as it allows for minimized load times, especially for db-fiddles that need to be created from scratch (not forked). It should be turned off though if the user is planning on navigating questions in a non-sequential manner or if there are computer performance issues. 58 | 59 | ## Known Issues 60 | * Some problems don't have actual table data. For example problem #175 only includes table schemas, so no tables can be parsed, the table schemas need to be loaded manually into db-fiddle.com. 61 | * DB-fiddle.com issues 62 | * Columns with only blank values are parsed as INTEGER in db-fiddle's text to DDL parser. However, this ends up throwing an error when the tables are actually queried on. This issue occurs for problem #586, a simple fix is switching from INTEGER to VARCHAR(1) in the table schema. 63 | * Columns with %Y-%m, i.e. '2017-05' are parsed as DATETIME when they should be parsed as VARCHAR inside db-fiddle's text to DDL. A simple fix is switching from DATETIME to VARCHAR(7) in the table schema. This issue occurs for #615. 64 | 65 | ## DB Fiddle url quick access 66 | For convenience, [this file](db_fiddle_public_urls.md) contains db-fiddle links for each LeetCode SQL problem. This might be useful if you want to to test your queries on just a few problems, you don't have Python, etc. 67 | 68 | ## Future Ideas 69 | * Create public db-fiddles for Postgres and sqlite as well. 70 | * Create db-fiddle solution links for each question instead of using github solutions which doesn't contain every solution and loads slower. 71 | -------------------------------------------------------------------------------- /db_fiddle_public_urls.md: -------------------------------------------------------------------------------- 1 | {175: 'https://www.db-fiddle.com/f/np7xuAhtJnUxYYBneRM2zv/0', 2 | 176: 'https://www.db-fiddle.com/f/32YsRKnUjtyy1qmYYbUAbn/0', 3 | 177: 'https://www.db-fiddle.com/f/seDYoeUPznVsqSEBCqScBt/0', 4 | 178: 'https://www.db-fiddle.com/f/6c6SByYhSsQrKWiKn7spcc/0', 5 | 180: 'https://www.db-fiddle.com/f/kAPuueJ2fUbSqfnfwqdcf/0', 6 | 181: 'https://www.db-fiddle.com/f/skWkHvm6Fazs1zTbVn46o3/0', 7 | 182: 'https://www.db-fiddle.com/f/qEXCKpHPcRJXUTRtuCmeha/0', 8 | 183: 'https://www.db-fiddle.com/f/7ZtLswpap9ZpXMouxkn2QZ/0', 9 | 184: 'https://www.db-fiddle.com/f/nEdNSW37ecU61xpwAKqThL/0', 10 | 185: 'https://www.db-fiddle.com/f/ieMWoLy9BWP4t6WAbv5R3h/0', 11 | 196: 'https://www.db-fiddle.com/f/mfKXo1K9j1o9gxE5RQ4Loo/0', 12 | 197: 'https://www.db-fiddle.com/f/uCmFfMsYxUQ79qyp3qJTq1/0', 13 | 262: 'https://www.db-fiddle.com/f/sCGhSYzLnudSkN1vUUHeH8/0', 14 | 511: 'https://www.db-fiddle.com/f/xvES6g1srxH1MvftJsKDxD/0', 15 | 512: 'https://www.db-fiddle.com/f/mc4g1unZ3SEn875BtB1AcE/0', 16 | 534: 'https://www.db-fiddle.com/f/akisscAqj8TzP1mnDmbU98/0', 17 | 550: 'https://www.db-fiddle.com/f/hHWz5hbQAnpHNBV2BvGC8D/0', 18 | 569: 'https://www.db-fiddle.com/f/4ubDMznT7HKGQQGmFEDY2D/0', 19 | 570: 'https://www.db-fiddle.com/f/5d2bTeFAzDkBLPc6EYwy6T/0', 20 | 571: 'https://www.db-fiddle.com/f/7ksVDcd2eUAUnJATwCMLJm/0', 21 | 574: 'https://www.db-fiddle.com/f/4UQpe9ZoK8hh25gK9UwfGj/0', 22 | 577: 'https://www.db-fiddle.com/f/tY5URUyW2EFQVXdk22Cogr/0', 23 | 578: 'https://www.db-fiddle.com/f/795JZJjbeWfjsLG6WphGQ2/0', 24 | 579: 'https://www.db-fiddle.com/f/sq48nWBzv4RE41pmbYHYCz/0', 25 | 580: 'https://www.db-fiddle.com/f/saTQrXvUsmRDZEwHDx1duU/0', 26 | 584: 'https://www.db-fiddle.com/f/dgJAPTWMJrQMe8MQWFeh8W/0', 27 | 585: 'https://www.db-fiddle.com/f/nsi1YsQmLhQN63mLAQsrbY/0', 28 | 586: 'https://www.db-fiddle.com/f/vgUiE3JoMPDGJ194ecUQxb/1', 29 | 595: 'https://www.db-fiddle.com/f/7KYhXSTBG4A2iNFtoghEFe/0', 30 | 596: 'https://www.db-fiddle.com/f/49pbXZ4fgoLsYTsuhbtetV/0', 31 | 597: 'https://www.db-fiddle.com/f/8VsCsvybjg3W6Zf3deGDJw/0', 32 | 601: 'https://www.db-fiddle.com/f/o5kh52ajtYheMhgSsGqhUq/0', 33 | 602: 'https://www.db-fiddle.com/f/9xFMVcRZ99Q9ZF3eHRnvUR/0', 34 | 603: 'https://www.db-fiddle.com/f/7n6GDoC9YzTCPj2Ja72zZr/0', 35 | 607: 'https://www.db-fiddle.com/f/g83C6ZUVhZfgdckkZqdEYN/0', 36 | 608: 'https://www.db-fiddle.com/f/bv7cf7TdJs71aTsjsgHorp/0', 37 | 610: 'https://www.db-fiddle.com/f/ge3yQ19Mqva2CkP69qoQL6/0', 38 | 612: 'https://www.db-fiddle.com/f/jdqRKVUFsfg5N65LGtog6r/0', 39 | 613: 'https://www.db-fiddle.com/f/2MJss5RG7D3CxuB5r2iajZ/0', 40 | 614: 'https://www.db-fiddle.com/f/bazaSBnp2j9bGRkmbRrxGz/0', 41 | 615: 'https://www.db-fiddle.com/f/iwob5Xj9dqwUCgofCqw9Hz/1', 42 | 618: 'https://www.db-fiddle.com/f/cRDZ8ndmvqdFt8Y3FcgqLG/0', 43 | 619: 'https://www.db-fiddle.com/f/6WUzt2hwKyPjySkyWe4BxW/0', 44 | 620: 'https://www.db-fiddle.com/f/hSDo3efTcQKdmoVKV2E9bx/0', 45 | 626: 'https://www.db-fiddle.com/f/oKKSyrLJy562sUCkQcT7aK/0', 46 | 627: 'https://www.db-fiddle.com/f/nJbCKbYjzRHFJs5bXVE1hC/0', 47 | 1045: 'https://www.db-fiddle.com/f/8J1GrYkgXFNU7RsBi8dADs/0', 48 | 1050: 'https://www.db-fiddle.com/f/naEeuikNKReqh3pa3WxTC/0', 49 | 1068: 'https://www.db-fiddle.com/f/rauDNfy76GRwD67J8QoeyL/0', 50 | 1069: 'https://www.db-fiddle.com/f/3kiKpJYVCCgjVbKkwm41fC/0', 51 | 1070: 'https://www.db-fiddle.com/f/6Ehg1L8vCSy5ZDreQF9PDQ/0', 52 | 1075: 'https://www.db-fiddle.com/f/u5X142mBcwnNiVPQX6J2y7/0', 53 | 1076: 'https://www.db-fiddle.com/f/hhLaZVH57UsVaCRQ3PJ7gg/0', 54 | 1077: 'https://www.db-fiddle.com/f/5rajKc4RunpD9hg5zU7J3Z/0', 55 | 1082: 'https://www.db-fiddle.com/f/semCR5rtBx3WWxKskS2WZ4/0', 56 | 1083: 'https://www.db-fiddle.com/f/afZHD687U9kEX3EHaVHbtg/0', 57 | 1084: 'https://www.db-fiddle.com/f/MRenxB2syxhH98DCJVaQF/0', 58 | 1097: 'https://www.db-fiddle.com/f/w25ghRL9tDWvAh62XF5kZQ/0', 59 | 1098: 'https://www.db-fiddle.com/f/i4fxw8Lc2zVXEDkaqgqPW8/0', 60 | 1107: 'https://www.db-fiddle.com/f/rpHL2nahbWYo2oe6caR6c5/0', 61 | 1112: 'https://www.db-fiddle.com/f/eAUrBTavdFTLYM9FbgasKr/0', 62 | 1113: 'https://www.db-fiddle.com/f/ttk8geUkVVeKZijPnViK3/0', 63 | 1126: 'https://www.db-fiddle.com/f/qkYogod4Fp86mkcEatGgWE/0', 64 | 1127: 'https://www.db-fiddle.com/f/g3krvZCRzdhwSgHE4Sqo1X/0', 65 | 1132: 'https://www.db-fiddle.com/f/45xhRswQ7N6pX8aTA57yQ5/0', 66 | 1141: 'https://www.db-fiddle.com/f/dvejoEiWpftJyyZqh4b87P/0', 67 | 1142: 'https://www.db-fiddle.com/f/dWTGKC3NK5VyoMrgj1AKRG/0', 68 | 1148: 'https://www.db-fiddle.com/f/3YEXa4N1fg3aj5LPX9c8dc/0', 69 | 1149: 'https://www.db-fiddle.com/f/sXJTXFPn7L2NDhhKL9PkSv/0', 70 | 1158: 'https://www.db-fiddle.com/f/7VsCiKYYoARxkFd386QhdF/0', 71 | 1159: 'https://www.db-fiddle.com/f/o4M1SnGKTNEHZLfwSrTREH/0', 72 | 1164: 'https://www.db-fiddle.com/f/dC9eSyehbjPM45rz7VG9Lk/0', 73 | 1173: 'https://www.db-fiddle.com/f/aYdXL8jFeLtkmUqhZGLXWS/0', 74 | 1174: 'https://www.db-fiddle.com/f/se7oDojg1kTN6VfGrumApw/0', 75 | 1179: 'https://www.db-fiddle.com/f/jwe5dyUXLuLLv5TG7ktVT1/0', 76 | 1193: 'https://www.db-fiddle.com/f/4KcsKFe94AmPW9HWDu6Bh9/0', 77 | 1194: 'https://www.db-fiddle.com/f/aZGFSLGhR39v5ytzJXsR5A/0', 78 | 1204: 'https://www.db-fiddle.com/f/bD65biCHxxAeT4dsKk9Jgp/0', 79 | 1205: 'https://www.db-fiddle.com/f/9sFjunxKehRkUimahAnTgu/0', 80 | 1211: 'https://www.db-fiddle.com/f/nc6cNnX4JJWd7S3t1Uv6jb/0', 81 | 1212: 'https://www.db-fiddle.com/f/b2RKpom6VtEq6yghRPdUA4/0', 82 | 1225: 'https://www.db-fiddle.com/f/sMYYKv3rc45jCVEiAZg8PR/0', 83 | 1241: 'https://www.db-fiddle.com/f/hay5utSSSbmRGpHFNRMRAw/0', 84 | 1251: 'https://www.db-fiddle.com/f/qB97UJ31CTbY2cFwz4QF19/0', 85 | 1264: 'https://www.db-fiddle.com/f/cZ6wVRAQGPBc4BamTf52sW/0', 86 | 1270: 'https://www.db-fiddle.com/f/44A3rfLefkhSqAZSgwqH4h/0', 87 | 1280: 'https://www.db-fiddle.com/f/5JpjzxKmXYQycPdZYY7x6v/0', 88 | 1285: 'https://www.db-fiddle.com/f/a7rdMeNcx5CCHSfZETMfS5/0', 89 | 1294: 'https://www.db-fiddle.com/f/s3kYyMQmp57DpJv71zb3FL/0', 90 | 1303: 'https://www.db-fiddle.com/f/te9m9QbzW1oWD2hKByksfN/0', 91 | 1308: 'https://www.db-fiddle.com/f/kFM2Xy1KFbPDgwRCA91qH5/0', 92 | 1321: 'https://www.db-fiddle.com/f/uweX54RdSnk8VRUFnUiHzd/0', 93 | 1322: 'https://www.db-fiddle.com/f/rStJxi89mrmcj6QPdXkxai/0', 94 | 1327: 'https://www.db-fiddle.com/f/3E2Lkw2tjWNFGdsPSdcADC/0', 95 | 1336: 'https://www.db-fiddle.com/f/edkerpdNmZBMPtBEPn4r1r/0', 96 | 1341: 'https://www.db-fiddle.com/f/7Hty1wh8WweachEDrtXm91/0', 97 | 1350: 'https://www.db-fiddle.com/f/ASnvgRzUwDpVWH5Bsi1kS/0', 98 | 1355: 'https://www.db-fiddle.com/f/9tvz8gWVfYKx3NHBidsde4/0', 99 | 1364: 'https://www.db-fiddle.com/f/gZ1z92fPz673BQ1F9DMsEh/0', 100 | 1369: 'https://www.db-fiddle.com/f/rgHDxL1duktDVJTyRJdstW/0', 101 | 1378: 'https://www.db-fiddle.com/f/tGJaXf7AokkKGDnu8KbKgu/0', 102 | 1384: 'https://www.db-fiddle.com/f/eJQThXyuMSp5F5EiNvoRbC/0', 103 | 1393: 'https://www.db-fiddle.com/f/vKKcbnttf6QeYsQawxLFSF/0', 104 | 1398: 'https://www.db-fiddle.com/f/3b5P7VCPLUsGLH92qwSjvf/0', 105 | 1407: 'https://www.db-fiddle.com/f/foq8dQaXnaGJ96VsA5VABp/0', 106 | 1412: 'https://www.db-fiddle.com/f/5hLPcccRaGMS1FTZ6FfDep/0', 107 | 1421: 'https://www.db-fiddle.com/f/f13g42PqE8CUZuiWAZjw7j/0', 108 | 1435: 'https://www.db-fiddle.com/f/tnfgrE2ziW8neoAqRabwHK/0', 109 | 1440: 'https://www.db-fiddle.com/f/gFWLMdTQmgjk73PSSjXHry/0', 110 | 1445: 'https://www.db-fiddle.com/f/tWWUUvgHBQ3Ftz65ERYDfK/0', 111 | 1454: 'https://www.db-fiddle.com/f/3Esgo9YZpCKxmswFVbwgrS/0', 112 | 1459: 'https://www.db-fiddle.com/f/4yxBae1z8qBpzitEpKj4DA/0', 113 | 1468: 'https://www.db-fiddle.com/f/bg9fUssXXZfzdz4So2SrWz/0', 114 | 1479: 'https://www.db-fiddle.com/f/iU8Jukh9NMTH63Yx3CHTWn/0', 115 | 1484: 'https://www.db-fiddle.com/f/a5BJKWmupRPhg1WBQgcVi1/0', 116 | 1495: 'https://www.db-fiddle.com/f/aJwTeCfSKmY4wtXuBe1h1h/0', 117 | 1501: 'https://www.db-fiddle.com/f/qCmoHiwc2UfuPdYEDC5jq1/0', 118 | 1511: 'https://www.db-fiddle.com/f/qiJD1uEaqWDXktA7yvbDny/0', 119 | 1517: 'https://www.db-fiddle.com/f/4wj7awLTmvUjFmmTepBjRi/0', 120 | 1527: 'https://www.db-fiddle.com/f/akvy2K2VkHZeZKXyL6QkSi/0', 121 | 1532: 'https://www.db-fiddle.com/f/de7LEpcFb4rqv4tqvnHyQT/0', 122 | 1543: 'https://www.db-fiddle.com/f/bTzyYyHUyVGiMVmQWRRrqs/0', 123 | 1549: 'https://www.db-fiddle.com/f/shy1sSX8dyTwkK2hN9v5eN/0', 124 | 1555: 'https://www.db-fiddle.com/f/wyXumtXKgpXhFpJiq5tzhN/0', 125 | 1795: 'https://www.db-fiddle.com/f/mvSGvifrBT4tsLBF9egQGn/0' 126 | } 127 | -------------------------------------------------------------------------------- /img/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjjchens235/leetcode-sql-unlocked/1734b51a5177a348486b7d26879a7968d8f0121b/img/demo2.gif -------------------------------------------------------------------------------- /leetcode_sql_unlocked/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjjchens235/leetcode-sql-unlocked/1734b51a5177a348486b7d26879a7968d8f0121b/leetcode_sql_unlocked/__init__.py -------------------------------------------------------------------------------- /leetcode_sql_unlocked/leetcode_sql_unlocked.py: -------------------------------------------------------------------------------- 1 | ''' 2 | The main module that instantiates and controls the behavior and interaction of all objects, most notably objects from WebHandler, QuestionNodes, and QuestionLog. 3 | ''' 4 | import os 5 | from shutil import copyfile 6 | import logging 7 | import traceback 8 | from datetime import datetime 9 | 10 | from selenium.common.exceptions import NoSuchWindowException, NoSuchElementException, WebDriverException 11 | 12 | from src.leetcode import Leetcode 13 | 14 | DRIVER_DIR = 'drivers' 15 | DRIVER = 'chromedriver' 16 | 17 | LOG_DIR = 'logs' 18 | ERROR_LOG = 'error.log' 19 | Q_ELEMENTS_LOG = 'q_elements.log' 20 | Q_STATE_LOG = 'q_state.log' 21 | Q_PUBLIC_URLS_LOG = 'q_public_urls.log' 22 | 23 | def setup_dirs(): 24 | try: 25 | os.chdir(os.path.dirname(os.path.realpath(__file__))) 26 | if not os.path.exists(DRIVER_DIR): 27 | os.mkdir(DRIVER_DIR) 28 | if not os.path.exists(LOG_DIR): 29 | os.mkdir(LOG_DIR) 30 | #running from shell, no __file__ var 31 | except NameError: 32 | print('CAUTION: __file__ variable could not be determined so directory check could not be completed') 33 | 34 | def copy_public_urls(target): 35 | src = os.path.join(os.path.dirname(os.getcwd()), 'db_fiddle_public_urls.md') 36 | if not os.path.exists(src): 37 | with open(src, 'w') as f: 38 | f.write('{}') 39 | copyfile(src, target) 40 | 41 | def get_leetcode(headless=False): 42 | setup_dirs() 43 | driver_path = os.path.join(DRIVER_DIR, DRIVER) 44 | q_elements_path = os.path.join(LOG_DIR, Q_ELEMENTS_LOG) 45 | q_state_path = os.path.join(LOG_DIR, Q_STATE_LOG) 46 | q_public_urls_path = os.path.join(LOG_DIR, Q_PUBLIC_URLS_LOG) 47 | if not os.path.exists(q_public_urls_path): 48 | copy_public_urls(q_public_urls_path) 49 | lc = Leetcode(driver_path, q_elements_path, q_state_path, q_public_urls_path, headless=headless) 50 | return lc 51 | 52 | def main(): 53 | lc = get_leetcode() 54 | logging.basicConfig(level=logging.ERROR, format='%(message)s', filename=os.path.join(LOG_DIR, ERROR_LOG)) 55 | is_continue, tb = True, None 56 | try: 57 | lc.start_new_question(q_num=lc.get_current_q_num()) 58 | while is_continue: 59 | is_continue = lc.options(lc.get_user_input()) 60 | 61 | except (NoSuchWindowException, WebDriverException): 62 | tb = traceback.format_exc() 63 | msg = 'Lost connection with browser, exiting now' 64 | except NoSuchElementException: 65 | tb = traceback.format_exc() 66 | msg = 'Web element not found, exiting now' 67 | except: 68 | tb = traceback.format_exc() 69 | msg = 'Uncaught exc, check logs/error.log, exiting now' 70 | finally: 71 | if tb: 72 | now = datetime.now().strftime("\n%Y-%m-%d %H:%M:%S ") 73 | logging.exception(now + msg + '\n' + tb) 74 | lc.exit(msg) 75 | 76 | if __name__ == '__main__': 77 | main() 78 | -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjjchens235/leetcode-sql-unlocked/1734b51a5177a348486b7d26879a7968d8f0121b/leetcode_sql_unlocked/src/__init__.py -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/driver.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | from urllib.request import urlopen 4 | import ssl 5 | import zipfile 6 | import re 7 | 8 | from selenium import webdriver 9 | from selenium.webdriver.support.abstract_event_listener import AbstractEventListener 10 | from selenium.webdriver.support.event_firing_webdriver import EventFiringWebDriver 11 | 12 | class EventListener(AbstractEventListener): 13 | """Attempt to disable animations""" 14 | def after_click_on(self, url, driver): 15 | animation =\ 16 | """ 17 | try { jQuery.fx.off = true; } catch(e) {} 18 | """ 19 | driver.execute_script(animation) 20 | 21 | class Driver: 22 | #agent src: https://www.whatismybrowser.com/guides/the-latest-user-agent/edge 23 | __WEB_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36 Edg/94.0.992.50" 24 | 25 | 26 | def __download_driver(driver_path, system, try_count=0): 27 | # determine latest chromedriver version 28 | #version selection faq: http://chromedriver.chromium.org/downloads/version-selection 29 | CHROME_RELEASE_URL = "https://sites.google.com/chromium.org/driver/downloads?authuser=0" 30 | try: 31 | response = urlopen( 32 | CHROME_RELEASE_URL, 33 | context=ssl.SSLContext(ssl.PROTOCOL_TLS) 34 | ).read() 35 | except ssl.SSLError: 36 | response = urlopen( 37 | CHROME_RELEASE_URL 38 | ).read() 39 | #download second latest version,most recent is sometimes not out to public yet 40 | 41 | latest_version = re.findall( 42 | b"ChromeDriver \d{2,3}\.0\.\d{4}\.\d+", response 43 | )[try_count].decode().split()[1] 44 | print('Downloading chromedriver version: ' + latest_version) 45 | 46 | if system == "Windows": 47 | url = "https://chromedriver.storage.googleapis.com/{}/chromedriver_win32.zip".format( 48 | latest_version 49 | ) 50 | elif system == "Darwin": 51 | url = "https://chromedriver.storage.googleapis.com/{}/chromedriver_mac64.zip".format( 52 | latest_version 53 | ) 54 | elif system == "Linux": 55 | url = "https://chromedriver.storage.googleapis.com/{}/chromedriver_linux64.zip".format( 56 | latest_version 57 | ) 58 | 59 | try: 60 | response = urlopen( 61 | url, context=ssl.SSLContext(ssl.PROTOCOL_TLS) 62 | ) # context args for mac 63 | except ssl.SSLError: 64 | response = urlopen(url) # context args for mac 65 | zip_file_path = os.path.join( 66 | os.path.dirname(driver_path), os.path.basename(url) 67 | ) 68 | with open(zip_file_path, 'wb') as zip_file: 69 | while True: 70 | chunk = response.read(1024) 71 | if not chunk: 72 | break 73 | zip_file.write(chunk) 74 | 75 | extracted_dir = os.path.splitext(zip_file_path)[0] 76 | with zipfile.ZipFile(zip_file_path, "r") as zip_file: 77 | zip_file.extractall(extracted_dir) 78 | os.remove(zip_file_path) 79 | 80 | driver = os.listdir(extracted_dir)[0] 81 | try: 82 | os.rename(os.path.join(extracted_dir, driver), driver_path) 83 | #for Windows 84 | except FileExistsError: 85 | os.replace(os.path.join(extracted_dir, driver), driver_path) 86 | 87 | os.rmdir(extracted_dir) 88 | os.chmod(driver_path, 0o755) 89 | 90 | def get_driver(path, headless=False): 91 | system = platform.system() 92 | if system == "Windows": 93 | if not path.endswith(".exe"): 94 | path += ".exe" 95 | if not os.path.exists(path): 96 | Driver.__download_driver(path, system) 97 | 98 | options = webdriver.ChromeOptions() 99 | options.add_argument("--disable-extensions") 100 | options.add_argument("--window-size=1280,1024") 101 | options.add_argument("--log-level=3") 102 | options.add_experimental_option("prefs", {"profile.default_content_setting_values.geolocation" : 1}) # geolocation permission, 0=Ask, 1=Allow, 2=Deny 103 | 104 | options.add_argument("user-agent=" + Driver.__WEB_USER_AGENT) 105 | if headless: 106 | #for this program, headless should only be used for testing or to set-up all the db-fiddles before hand 107 | options.add_argument("--headless") 108 | 109 | driver_dl_index = 1 110 | while True: 111 | try: 112 | driver = webdriver.Chrome(path, options=options) 113 | break 114 | #driver not up to date with Chrome browser, try different ver 115 | except: 116 | Driver.__download_driver(path, system, driver_dl_index) 117 | driver_dl_index += 1 118 | if driver_dl_index > 2: 119 | print(f'Tried downloading the {driver_dl_index} most recent chrome drivers. None match current Chrome browser version') 120 | break 121 | return EventFiringWebDriver(driver, EventListener()) 122 | -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/exc_thread.py: -------------------------------------------------------------------------------- 1 | from threading import Thread, Event 2 | 3 | class ExcThread(Thread): 4 | 5 | def run(self): 6 | self.exc = None 7 | try: 8 | # Possibly throws an exception 9 | #threading.Thread.start(self) 10 | super().run() 11 | 12 | except: 13 | print('made it to exc thread exception block') 14 | import sys 15 | self.exc = sys.exc_info() 16 | # Save details of the exception thrown but don't rethrow, 17 | # just complete the function 18 | 19 | def join(self): 20 | super().join() 21 | if self.exc: 22 | msg = "Thread '%s' threw an exception: %s" % (self.getName(), self.exc[1]) 23 | new_exc = Exception(msg) 24 | raise new_exc.with_traceback(self.exc[2]) 25 | 26 | """ 27 | def is_started(self): 28 | ''' 29 | A thread is started if the thread has called start() already. 30 | This matters because once start() is called a thread can't call it 31 | again. 32 | Furthermore, join() can only be called once it has been started 33 | ''' 34 | return self._started.is_set() 35 | """ 36 | -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/help_menu.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | class HelpMenu: 5 | ''' 6 | Using argparse library for unintended purpose- to format help menu 7 | ''' 8 | def __init__(self, default_num_to_display): 9 | #setting max_help_position to keep keyword and help description on same line 10 | formatter = lambda prog: argparse.HelpFormatter(prog, max_help_position=40) 11 | parser = argparse.ArgumentParser(formatter_class = formatter, usage=argparse.SUPPRESS) 12 | # this pop hack: https://stackoverflow.com/questions/24180527/argparse-required-arguments-listed-under-optional-arguments 13 | parser._action_groups.pop() 14 | parser._positionals.title = 'Help Menu' 15 | parser.add_argument("(h)elp", help='Show this help menu') 16 | parser.add_argument("(n)ext [LEVEL]", help="Select next problem. Optionally, go next by level [(e)asy, (m)edium, (h)ard], default ignores levels. I.e. 'n' or 'n e' or 'next easy'" ) 17 | parser.add_argument("(q)uestion NUMBER", help="Select problem by question number i.e. 'q 183' or 'question 183' or simply '183' ") 18 | parser.add_argument("(s)olution", help='Open solution of current problem in new chrome tab') 19 | parser.add_argument("(d)isplay [LEVEL] [# TO DISPLAY]", help="Displays list of problems. Optionally, can display by level [(e)asy, (m)edium, (h)ard]. Optionally, can also choose how many problems to display, default is {default1}. Ex: 'd' is to display next {default2} problems of all levels, 'd e 30' is to display the next 30 easy problems.".format(default1=default_num_to_display, default2=default_num_to_display)) 20 | parser.add_argument("(l)oad ON/OFF", help="Pre-load additional questions in the background for faster future question access. Ex: 'l on' is to turn load on, and 'l off' is to turn load off") 21 | parser.add_argument("(e)xit", help='Exit program') 22 | self.parser = parser 23 | 24 | def print_help(self): 25 | print('\n') 26 | self.parser.print_help() 27 | time.sleep(1.5) 28 | 29 | if __name__ == '__main__': 30 | help_menu = HelpMenu() 31 | help_menu.print_help() 32 | 33 | -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/leetcode.py: -------------------------------------------------------------------------------- 1 | ''' 2 | The main module that instantiates and controls the behavior and interaction of all objects, most notably objects from WebHandler, QuestionNodes, and QuestionLog. 3 | ''' 4 | import os 5 | import re 6 | import time 7 | from datetime import datetime 8 | from threading import Event 9 | 10 | from .config import cfg 11 | from .help_menu import HelpMenu 12 | from .questions import QuestionNodes 13 | from .driver import Driver 14 | from .web_handler import WebHandler 15 | from .log import QuestionLog 16 | from .exc_thread import ExcThread 17 | 18 | class Leetcode(): 19 | 20 | def __init__(self, driver_path, q_elements_path, q_state_path, q_public_urls_path, headless=False): 21 | self.cfg = cfg 22 | self.driver_path = driver_path 23 | self.web_handler = WebHandler(self.driver_path, headless) 24 | self.question_log = QuestionLog(q_elements_path, q_state_path, q_public_urls_path) 25 | 26 | q_elements = self.web_handler.get_question_elements() 27 | self.question_log.write_dict(q_elements_path, q_elements) 28 | self.question_nodes = QuestionNodes(q_elements, self.question_log.q_state['current']) 29 | 30 | if self.cfg['is_preload']: 31 | self.__turn_on_preloading() 32 | 33 | def get_current_q(self): 34 | ''' 35 | Get the current question node object 36 | ''' 37 | return self.question_nodes.get_current() 38 | 39 | def get_current_q_num(self): 40 | ''' 41 | Get the current question nodes number 42 | ''' 43 | return self.question_nodes.get_current_num() 44 | 45 | def preload_finish(self): 46 | ''' 47 | Turn on stop event so that main preload method will stop creating 48 | new db-fiddles. 49 | Join() waits for the preload method to finish its current 50 | db-fiddle, before opening a new question in main thread. 51 | Even if the thread is already finished, call join() for exec info. 52 | ''' 53 | #tell preload thread to end 54 | if self.preloader.thread is not None: 55 | if self.preloader.thread.is_alive(): 56 | self.preloader.stop_event.set() 57 | print('\nWrapping up current question being pre-loaded') 58 | #wait for preload thread to finish its current question 59 | self.preloader.thread.join() 60 | self.preloader.stop_event.clear() 61 | 62 | def preload_delay(self, question_index): 63 | ''' 64 | Based on number of questions to preload, delay processing the next pre-loaded question so as not to send too much traffic at one time to external website 65 | ''' 66 | if question_index <= 5: 67 | return 68 | elif question_index <= 15: 69 | delay = 10 70 | elif question_index <= 40: 71 | delay = 30 72 | else: 73 | delay = 45 74 | self.preloader.stop_event.wait(delay) 75 | 76 | def preload_open_question(self, q_num): 77 | if self.check_is_forkable(q_num): 78 | public_url = self.question_log.q_public_urls[q_num] 79 | start_url = self.preloader.web_handler.open_fork(q_num, public_url) 80 | else: 81 | start_url = self.preloader.web_handler.open_question(q_num, self.cfg['db_engine'], self.cfg['is_check_new_save_versions']) 82 | #check that the question still doesn't exist in the log before writing just the url to it 83 | if start_url is not None and not self.question_log.is_q_exist(q_num): 84 | self.question_log.update_q_url(q_num, start_url) 85 | self.question_log.write_dict(self.question_log.q_state_path, self.question_log.q_state) 86 | 87 | def preload_close_question(self): 88 | self.preloader.web_handler.close_question(is_save_before_closing=False) 89 | 90 | def preload_question(self, q_num): 91 | self.preload_open_question(q_num) 92 | self.preload_close_question() 93 | 94 | def preload(self, n_next, n_next_same_lvl): 95 | ''' 96 | Headless web handler in the background to create db-fiddles for pre-loading. 97 | This will try to guarantee the next n questions are pre-loaded. 98 | However, if the question's db-fiddle already exists, no need to pre-load. 99 | If user selects another question before all n questions can be pre-loaded, thread will be terminated after the current question being preloaded is finished 100 | ''' 101 | q_curr = self.get_current_q() 102 | next_q_nums = [q.number for q in self.question_nodes.get_next_n_nodes(n_next)] 103 | next_same_lvl_q_nums = [q.number for q in self.question_nodes.get_next_n_nodes(n_next_same_lvl, q_curr.level)] 104 | question_nums = sorted(set(next_q_nums + next_same_lvl_q_nums)) 105 | question_nums = [q_num for q_num in question_nums if not self.question_log.is_q_exist(q_num) and q_num != 175] 106 | #print(f'in preload, node(s) to be processed are {question_nums}') 107 | 108 | for i, q_num in enumerate(question_nums): 109 | # if main thread is still running 110 | #and question has never been created 111 | if self.preloader.stop_event.is_set(): 112 | return 113 | self.preload_question(q_num) 114 | self.preload_delay(i) 115 | if len(question_nums) > 5: 116 | print('FYI, current batch of questions have finished preloading') 117 | 118 | def close_current_question(self): 119 | q_num = self.get_current_q_num() 120 | try: 121 | start_url = self.question_log.q_state['url'][q_num] 122 | #no valid url was created in open_new_questions() 123 | except: 124 | start_url = None 125 | end_url = self.web_handler.close_question(self.cfg['is_save_before_closing']) 126 | if end_url not in (None, 'https://www.db-fiddle.com/'): 127 | self.question_log.update_q_state(q_num, end_url) 128 | 129 | def check_is_forkable(self, q_num): 130 | ''' 131 | Check that 132 | 1) the question has not been saved to the personal state log yet. 133 | 2) The question has a public url to fork 134 | 3) The user wants to fork an exiting public url 135 | ''' 136 | cond1 = not self.question_log.is_q_exist(q_num) 137 | cond2 = self.question_log.is_q_public_exist(q_num) 138 | cond3 = self.cfg['is_fork_public_url'] 139 | if cond1 and cond2 and cond3: 140 | return True 141 | return False 142 | 143 | def open_new_question(self, q_num=None): 144 | if q_num is None: 145 | q_num = self.get_current_q_num() 146 | #print(f'inside open_new_question, q_num is {q_num}') 147 | try: 148 | prev_save_url = self.question_log.q_state['url'][q_num] 149 | except KeyError: 150 | prev_save_url = None 151 | 152 | if self.check_is_forkable(q_num): 153 | public_url = self.question_log.q_public_urls[q_num] 154 | start_url = self.web_handler.open_fork(q_num, public_url) 155 | else: 156 | start_url = self.web_handler.open_question(q_num, self.cfg['db_engine'], self.cfg['is_check_new_save_versions'], prev_save_url) 157 | 158 | if start_url is not None: 159 | self.question_log.update_q_state(q_num, start_url) 160 | else: 161 | print('\n\nCAUTION: For question {}, not able to parse tables from leetcode.jp'.format(q_num)) 162 | 163 | if self.__is_preload_questions: 164 | #start preloading now that user selected question is loaded 165 | self.preloader.thread = ExcThread(target=self.preload, args=(self.cfg['n_to_preload'], self.cfg['n_same_level_to_preload'])) 166 | self.preloader.thread.start() 167 | 168 | def start_new_question(self, q_level=None, q_num=None): 169 | if self.__is_preload_questions: 170 | self.preload_finish() 171 | 172 | self.close_current_question() 173 | #updates question current to the question the user chose 174 | if q_num is not None: 175 | self.question_nodes.select_question_by_number(q_num) 176 | else: 177 | self.question_nodes.select_next_question(q_level) 178 | self.open_new_question() 179 | 180 | @staticmethod 181 | def print_options(expr, sleep_time=1): 182 | print('\n'+ expr+ '\n') 183 | time.sleep(sleep_time) 184 | 185 | def next_option(self, user_input): 186 | ''' 187 | if user selects next 188 | ''' 189 | if user_input in ['ne', 'nm', 'nh']: 190 | user_input = ' '.join(user_input) 191 | user_inputs = user_input.split() 192 | if user_inputs[0] in ('n', 'next'): 193 | q_level = None 194 | #ignore q_level 195 | if len(user_inputs) == 1: 196 | self.print_options("You chose next question") 197 | #by q_level 198 | elif len(user_inputs) == 2: 199 | user_level = user_inputs[1] 200 | if user_level in ('e', 'easy'): 201 | q_level = 'easy' 202 | elif user_level in ('m', 'medium'): 203 | q_level = 'medium' 204 | elif user_level in ('h', 'hard'): 205 | q_level = 'hard' 206 | else: 207 | self.print_options("Invalid next command!! Do you want to go to next question? Either use 'n' or try by level i.e. 'n e' for next easy, 'n m', 'n h'") 208 | return 209 | self.print_options("You chose next {q_level} question".format(q_level=q_level)) 210 | else: 211 | self.print_options("Invalid input!! Too many arguments for next command. Either use 'n' or try by level i.e. 'n e' for next easy, 'n m', 'n h'") 212 | return 213 | 214 | self.start_new_question(q_level=q_level) 215 | 216 | #command starts with 'n', but not start with 'n' or 'next' 217 | else: 218 | self.print_options("Invalid input!! Do you want to go to next question? Either use 'n' or try by level i.e. 'n e' for next easy, 'n m', 'n h'") 219 | 220 | def question_by_number_option(self, user_input): 221 | ''' 222 | Checks that the user input is a string that starts with q and ends with a number 223 | If the input is valid, change question to number inputted 224 | ''' 225 | matches = re.match(r'[a-z\s]*(\d+)', user_input) 226 | if matches: 227 | q_num = int(matches.group(1)) 228 | if self.question_nodes.is_q_exist(q_num): 229 | self.print_options('You chose question {q_num}'.format(q_num=q_num)) 230 | self.start_new_question(q_num=q_num) 231 | else: 232 | self.print_options("Question number inputted is not on question list. Please enter valid question number, press 'd' to see list of questions",1.5) 233 | else: 234 | self.print_options("Invalid input. If you want to select by question number input 'q NUMBER'. Else if you want to quit, press 'e' to exit") 235 | 236 | def parse_display_args(self, user_input): 237 | ''' 238 | Unsophisticated way of parsing out what display options the user wants. If the input starts with 'd', immediately assume its display. If the letters 'e','m','h' are included that means a level is being specified. Use regex to check if user specified n questions to display 239 | ''' 240 | level_arg = None 241 | if 'm' in user_input: 242 | level_arg = 'medium' 243 | elif 'h' in user_input: 244 | level_arg = 'hard' 245 | elif 'e' in user_input: 246 | level_arg = 'easy' 247 | 248 | num_to_display_arg = None 249 | try: 250 | num_to_display_arg = int(re.search(r'\d+', user_input).group(0)) 251 | except AttributeError: 252 | pass 253 | return level_arg, num_to_display_arg 254 | 255 | def display_questions_option(self, user_input): 256 | ''' 257 | Main display method 258 | ''' 259 | level_arg, num_to_display_arg = self.parse_display_args(user_input) 260 | print() 261 | self.question_nodes.display_questions(level_arg, num_to_display_arg) 262 | 263 | def help_option(self): 264 | h = HelpMenu(self.question_nodes.DEFAULT_NUM_TO_DISPLAY) 265 | h.print_help() 266 | 267 | def solution_option(self): 268 | self.web_handler.open_solution_win(self.get_current_q()) 269 | 270 | def __turn_off_preloading(self): 271 | self.preload_finish() 272 | self.__is_preload_questions = False 273 | 274 | def turn_off_preloading(self): 275 | self.print_options('Turning OFF question loading for the duration of this program.') 276 | if self.__is_preload_questions: 277 | self.__turn_off_preloading() 278 | 279 | class Preloader: 280 | def __init__(self, thread, web_handler): 281 | self.thread = thread 282 | self.web_handler = web_handler 283 | self.stop_event = Event() 284 | 285 | def __turn_on_preloading(self): 286 | self.__is_preload_questions = True 287 | if not hasattr(self, 'preloader'): 288 | self.preloader = self.Preloader(thread=None, web_handler=WebHandler(self.driver_path, headless=True)) 289 | 290 | def turn_on_preloading(self): 291 | #possibilities, load has never been turned on 292 | # load was previously off but was turned on at one point 293 | #load was previously on 294 | self.print_options('Turning ON question loading for the duration of this program.') 295 | if not self.__is_preload_questions: 296 | self.__turn_on_preloading() 297 | 298 | def preload_option(self, user_input): 299 | if 'on' in user_input: 300 | self.turn_on_preloading() 301 | elif 'off' in user_input: 302 | self.turn_off_preloading() 303 | return True 304 | 305 | def exit(self, msg="Exiting program"): 306 | self.web_handler.close_all() 307 | if self.__is_preload_questions: 308 | self.preload_finish() 309 | if hasattr(self, 'preload_thread'): 310 | self.preloader.web_handler.close_all() 311 | print(msg + '\n') 312 | return False 313 | 314 | def exit_option(self, msg="Exiting program"): 315 | ''' 316 | If the user inputs 'e' into console, we can save the state of the question to the log before closing everything 317 | ''' 318 | self.close_current_question() 319 | return self.exit(msg) 320 | 321 | def get_user_input(self): 322 | user_input = input("\n\n----------------------------------------\nYou are on {name}\n\nWhat would you like to do next?\nType 'n' for next problem, 'h' for more help/options, 'e' to exit\n".format(name=self.get_current_q().name)) 323 | return user_input 324 | 325 | @staticmethod 326 | def clean_user_input(user_input): 327 | ''' 328 | cleans invalid characters from user input 329 | ''' 330 | pattern = re.compile(r'[^A-Za-z0-9\s]+') 331 | sub = re.sub(pattern, '', user_input) 332 | return ' '.join(sub.split()).lower() 333 | 334 | def options(self, user_input): 335 | ''' 336 | handles user input 337 | ''' 338 | user_input = self.clean_user_input(user_input) 339 | #if input is numbers only, this will be treated as question # arg 340 | is_num_only = re.match(r'[1-9]\d{2,3}$', user_input) is not None 341 | valid_start_inputs = ['h', 'n', 'q', 's', 'd', 'l', 'e'] 342 | try: 343 | start_input = user_input[0] 344 | #empty user input 345 | except: 346 | self.print_options('Invalid input') 347 | return True 348 | 349 | if start_input in valid_start_inputs or is_num_only: 350 | if start_input == 'h': 351 | self.help_option() 352 | 353 | elif start_input == 'n': 354 | self.next_option(user_input) 355 | 356 | elif start_input == 'q' or is_num_only: 357 | if user_input == ('q'): 358 | self.print_options("Invalid input. If you want to select by question number input 'q NUMBER'. Else if you want to quit, press 'e' to exit") 359 | elif user_input == ('quit'): 360 | return self.exit_option() 361 | else: 362 | self.question_by_number_option(user_input) 363 | elif start_input == 's': 364 | self.solution_option() 365 | elif start_input == 'd': 366 | self.display_questions_option(user_input) 367 | elif start_input == 'l': 368 | if 'on' in user_input or 'off' in user_input: 369 | return self.preload_option(user_input) 370 | else: 371 | self.print_options('Invalid input') 372 | elif start_input == 'e': 373 | if user_input in ('e', 'exit'): 374 | return self.exit_option() 375 | else: 376 | self.print_options('Invalid input') 377 | #does not start with valid char 378 | else: 379 | self.print_options('Invalid input!') 380 | return True 381 | -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/log.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ast 3 | import pprint 4 | 5 | 6 | class QuestionLog: 7 | ''' 8 | The question log keeps track of two things. Firstly, q_elements, which is all the question data parsed from leetcode. The question elements are saved in a log so that the question info doesn't have to be downloaded each session since questions aren't being added that often. 9 | Secondly, q_state, which is the state of each of the questions, specifically which question the user is currently on, and a list of all questions that user has a db-fiddle of, including the URL of the db-fiddle. 10 | ''' 11 | 12 | def __init__(self, q_elements_path, q_state_path, q_public_urls_path): 13 | self.q_elements_path = q_elements_path 14 | self.q_state_path = q_state_path 15 | self.q_public_urls_path = q_public_urls_path 16 | 17 | self.q_elements = self.__read_dict(q_elements_path) 18 | self.q_state = self.__read_dict(q_state_path) 19 | self.q_public_urls = self.__read_dict(q_public_urls_path) 20 | 21 | if self.q_state is None: 22 | #If question state does not exist yet, start at question 176 23 | self.q_state = {'current':176,'url':{}} 24 | 25 | def __read_dict(self, path): 26 | if os.path.exists(path): 27 | with open(path, "r") as f: 28 | return ast.literal_eval(f.read()) 29 | return None 30 | 31 | def write_dict(self, path, dict): 32 | with open(path, 'w') as f: 33 | pprint.pprint(dict, f) 34 | 35 | def update_q_current(self, q_num): 36 | self.q_state['current'] = q_num 37 | 38 | def update_q_url(self, q_num, url): 39 | self.q_state['url'][q_num] = url 40 | 41 | def update_q_state(self, q_num, url): 42 | self.update_q_current(q_num) 43 | self.update_q_url(q_num, url) 44 | self.write_dict(self.q_state_path, self.q_state) 45 | 46 | def is_q_exist(self, q_num): 47 | return q_num in self.q_state['url'].keys() 48 | 49 | def is_q_public_exist(self, q_num): 50 | return q_num in self.q_public_urls.keys() 51 | -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/questions.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | import time 3 | 4 | class QuestionNode: 5 | ''' 6 | Each question node has a number, name, and level attribute and a pointer to the next node, as well as pointer to the next node of the same level 7 | ''' 8 | def __init__(self, number, name, level, next=None, next_same_lvl=None): 9 | self.number = number 10 | self.name = name 11 | self.level = level 12 | self.next = next 13 | self.next_same_lvl = next_same_lvl 14 | 15 | class QuestionNodes: 16 | ''' 17 | The questions are represented as a linked list type structure. The current node is saved in a log file, so the next question can be easily accessed. However, the user also has an option of selecting by question number, meaning there's potential of jumping many nodes across, so each question node can also be accessed by its number key. 18 | ''' 19 | DEFAULT_NUM_TO_DISPLAY = 15 20 | 21 | def __init__(self, question_elements, curr_log_num): 22 | self.__question_nodes = {} 23 | self.head = None 24 | self.tail = None 25 | self.__current = self.create_q_nodes(question_elements, curr_log_num) 26 | 27 | def create_q_nodes(self, question_elements, curr_log_num): 28 | head_easy = None 29 | head_med = None 30 | head_hard = None 31 | prev_q = None 32 | prev_q_easy = None 33 | prev_q_med = None 34 | prev_q_hard = None 35 | 36 | for q_num in sorted(question_elements.keys()): 37 | name = question_elements[q_num]['name'] 38 | level = question_elements[q_num]['level'] 39 | q = QuestionNode(q_num, name, level) 40 | self.__question_nodes[q_num] = q 41 | if q_num == curr_log_num: 42 | curr = q 43 | 44 | if not self.head: 45 | self.head = q 46 | 47 | if prev_q: 48 | prev_q.next = q 49 | prev_q = q 50 | 51 | if level == 'easy': 52 | if prev_q_easy: 53 | prev_q_easy.next_same_lvl = q 54 | else: 55 | head_easy = q 56 | prev_q_easy = q 57 | 58 | elif level == 'medium': 59 | if prev_q_med: 60 | prev_q_med.next_same_lvl = q 61 | else: 62 | head_med = q 63 | prev_q_med = q 64 | 65 | else: 66 | if prev_q_hard: 67 | prev_q_hard.next_same_lvl = q 68 | else: 69 | head_hard = q 70 | prev_q_hard = q 71 | 72 | self.tail = q 73 | q.next = self.head 74 | prev_q_easy.next_same_lvl = head_easy 75 | prev_q_med.next_same_lvl = head_med 76 | prev_q_hard.next_same_lvl = head_hard 77 | 78 | #if for some reason, the current question in the log is an invalid question #, reset question to #176 79 | if not self.is_q_exist(curr_log_num): 80 | curr = self.head.next 81 | return curr 82 | 83 | def print_q_nodes(self): 84 | curr = self.__current 85 | while curr.number != self.__current.number: 86 | print('Current Question: ' + str(curr.number) +', Question Name: ' + curr.name + ', Level: ' + curr.level + ', Next Question: ' + str(curr.next.number) + ' , Next Same Level Question: ' + str(curr.next_same_lvl.number) + '\n') 87 | curr = curr.next 88 | 89 | def get_current(self): 90 | return self.__current 91 | 92 | def get_current_num(self): 93 | return self.__current.number 94 | 95 | def get_next_node(self, node, level=None): 96 | if level is None: 97 | return node.next 98 | elif node.level == level: 99 | return node.next_same_lvl 100 | else: 101 | curr = node.next 102 | while curr.level != level: 103 | curr = curr.next 104 | return curr 105 | 106 | def select_next_question(self, level=None): 107 | ''' 108 | Returns the next question based on current. Can also return next question by level 109 | ''' 110 | self.__current = self.get_next_node(self.__current, level) 111 | 112 | def is_q_exist(self, number): 113 | try: 114 | self.__question_nodes[number] 115 | except KeyError: 116 | return False 117 | return True 118 | 119 | def select_question_by_number(self, number): 120 | self.__current = self.__question_nodes[number] 121 | 122 | def get_next_n_nodes(self, n, level=None): 123 | nodes = [] 124 | curr = self.__current 125 | head = None 126 | for i in range(n): 127 | curr = self.get_next_node(curr, level) 128 | #repeating questions 129 | if head is not None and head.number == curr.number: 130 | break 131 | nodes.append(curr) 132 | if i == 0: 133 | head = curr 134 | return nodes 135 | 136 | def display_questions(self, level=None , n=None): 137 | if n is None: 138 | n = self.DEFAULT_NUM_TO_DISPLAY 139 | q_names = [node.name for node in self.get_next_n_nodes(n, level)] 140 | print('\nDisplaying next {n}{level} questions:\n'.format(n=n, level= ' '+ level if level is not None else '')) 141 | pprint.pprint(q_names) 142 | time.sleep(2) 143 | return q_names 144 | -------------------------------------------------------------------------------- /leetcode_sql_unlocked/src/web_handler.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import itertools 4 | from datetime import datetime 5 | import requests 6 | 7 | from selenium.webdriver.common.keys import Keys 8 | from selenium.webdriver.support.ui import WebDriverWait 9 | from selenium.webdriver.support import expected_conditions as EC 10 | from selenium.webdriver.common.by import By 11 | from selenium.common.exceptions import NoSuchElementException, NoSuchWindowException, ElementNotSelectableException, ElementNotVisibleException, WebDriverException 12 | 13 | from .driver import Driver 14 | 15 | class WebHandler(): 16 | ''' 17 | Handles all selenium.webdriver actions including: 18 | 19 | Getting question elements from leetcode.com. 20 | All tab handling (opening, closing, switching, etc) 21 | -Specifically, opening leetcode.jp, db-fiddle, and solution tab 22 | 23 | Parses leetcode.jp in TableParser() sublclass for sql table data 24 | Inputs sql data to db-fiddle. 25 | ''' 26 | 27 | __WAIT_LONG = 7 28 | __WAIT_SHORT = 2 29 | 30 | def __init__(self, driver_path, headless): 31 | self.driver = Driver.get_driver(driver_path, headless=headless) 32 | #references to each question tab in webdriver 33 | self.leet_win = None 34 | self.db_win = None 35 | self.solution_win = None 36 | 37 | def get_question_elements(self): 38 | ''' 39 | Use Leetcode API to obtain question data 40 | ''' 41 | try: 42 | question_elements = {} 43 | levelnum_to_level = {1: 'easy', 2: 'medium', 3: 'hard'} 44 | 45 | url = 'https://leetcode.com/api/problems/database/' 46 | json = requests.get(url).json() 47 | questions = json['stat_status_pairs'] 48 | 49 | for question in questions: 50 | q_num = question['stat']['frontend_question_id'] 51 | level_num = question['difficulty']['level'] 52 | q_level = levelnum_to_level[level_num] 53 | q_title = question['stat']['question__title'] 54 | q_full_title = f"{q_num}: {q_title}, {q_level}" 55 | question_elements[q_num] = {'level': q_level, 'name': q_full_title} 56 | return question_elements 57 | 58 | except: 59 | print('\n Could not find question elements from leetcode.com') 60 | 61 | def close_window(self, window): 62 | try: 63 | if len(self.driver.window_handles) > 1: 64 | self.driver.switch_to.window(window) 65 | self.driver.close() 66 | except: 67 | pass 68 | 69 | def close_question_windows(self): 70 | windows = (self.leet_win, self.db_win, self.solution_win) 71 | for window in windows: 72 | if window is not None: 73 | self.close_window(window) 74 | self.leet_win = self.db_win = self.solution_win = None 75 | 76 | def close_all(self): 77 | try: 78 | self.driver.quit() 79 | #driver already quit 80 | except: 81 | print('\nCannot close driver, driver has already closed') 82 | 83 | def get_last_window(self): 84 | return self.driver.window_handles[-1] 85 | 86 | def reset_curr_window(self): 87 | try: 88 | self.driver.switch_to.window(self.get_last_window()) 89 | except: 90 | pass 91 | 92 | def open_new_win(self, url): 93 | ''' 94 | Open a new tab for specified url 95 | Note that driver.current_window_handle attribute is not updated when executing this 96 | ''' 97 | # need to always reset to an active window before opening new window b/c if opening from an inactive window, a non such window exception is triggered 98 | self.reset_curr_window() 99 | js_url = '\'' + url + '\'' 100 | script = "window.open({js_url})".format(js_url=js_url) 101 | try: 102 | self.driver.execute_script(script) 103 | #if js excecute_script doesn't work, open a new tab with ctr+t 104 | except WebDriverException: 105 | self.driver.find_element_by_tag_name('body').send_keys(Keys.CONTROL + 't') 106 | self.driver.get(url) 107 | self.reset_curr_window() 108 | return self.get_last_window() 109 | 110 | def is_valid_save_url(self, url): 111 | ''' 112 | Check if url is a saved db-fiddle. It needs to start with https://db-fiddle, and end with a /0-9 113 | ''' 114 | try: 115 | if re.search(r'^https://www\.db-fiddle.*/\d+$', url) is not None: 116 | return True 117 | except: 118 | pass 119 | return False 120 | 121 | def open_newest_fiddle_url(self, url): 122 | ''' 123 | Gets the newest version of the fiddle url 124 | Each saved fiddle url ends with a version #, and increments up each time its saved again 125 | If the fiddle url redirects to db-fiddle.com that means the previous version was the last version saved 126 | Note: I tried using requests module to check if url is valid, but even when the url is redirected, the request.is_redirect flag is still False 127 | ''' 128 | url_index = int(url[-1]) 129 | base_url = url[:-1] 130 | window_handles = [] 131 | 132 | while url != 'https://www.db-fiddle.com/': 133 | window_handles.append(self.open_new_win(base_url + str(url_index))) 134 | time.sleep(self.__WAIT_SHORT) 135 | url = self.driver.current_url 136 | url_index += 1 137 | 138 | # window_handles[-1] is always an invalid link that redirects to 'db-fiddle.com' 139 | self.db_win = window_handles[-2] 140 | 141 | for window in window_handles: 142 | if window != self.db_win: 143 | self.close_window(window) 144 | return base_url + str(url_index - 2) 145 | 146 | def open_solution_win(self, question): 147 | q_num = '\'' + str(question.number).zfill(4) + '\'' 148 | try: 149 | url = 'https://github.com/kamyu104/LeetCode-Solutions#sql' 150 | self.solution_win = self.open_new_win(url) 151 | #find question from github page 152 | WebDriverWait(self.driver, 3).until(EC.element_to_be_clickable((By.XPATH,("//*[contains(text(),{q_num})]/following-sibling::td/following-sibling::td".format(q_num=q_num))))).click() 153 | #find solution text, and scroll to it 154 | element = WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.presence_of_element_located((By.CSS_SELECTOR, 'div[itemprop="text"]'))) 155 | self.driver.execute_script("arguments[0].scrollIntoView();", element) 156 | except: 157 | print('\nNo GitHub solution found for this problem... doing a google search instead') 158 | base_url = 'https://www.google.com/search?q=' 159 | search_term = '+'.join(question.name.split()) + '+leetcode+solution' 160 | self.driver.get(base_url+ search_term) 161 | 162 | def get_leetcode_url(self, q_num): 163 | return 'https://leetcode.jp/problemdetail.php?id={q_num}'.format(q_num=q_num) 164 | 165 | def open_leetcode_win(self, q_num): 166 | ''' 167 | select the leetcode jp problem to go to 168 | ''' 169 | self.leet_win = self.open_new_win(self.get_leetcode_url(q_num)) 170 | 171 | class TableParser: 172 | __WAIT_LONG = 7 173 | __WAIT_SHORT = 2 174 | 175 | def __init__(self, driver): 176 | self.driver = driver 177 | 178 | def parse_table_pre(self): 179 | #find sql text tables using pre element tag 180 | elements_pre = self.driver.find_elements_by_css_selector("pre") 181 | tables_pre = [element.text for element in elements_pre] 182 | 183 | #use list() to make a copy so that removal of elements does not effect loop indexing 184 | for table_pre in list(tables_pre): 185 | #remove data type tables 186 | if 'Column Name' in table_pre or 'Column' in table_pre: 187 | tables_pre.remove(table_pre) 188 | return tables_pre 189 | 190 | def parse_table_lines(self, tables_pre): 191 | ''' 192 | tables_pre will contain some extra non-table lines 193 | This will remove any line that is not part of a table. 194 | The returned list will still need to be cleaned because the tables aren't seperated out 195 | ''' 196 | table_lines = [] 197 | for table_pre in tables_pre: 198 | matches = re.findall(r'\+--*.*[\+\|]|\|.*\|', table_pre) 199 | if len(matches) > 0: 200 | table_lines.append(matches) 201 | return table_lines 202 | 203 | def get_table_type(self, table_lines): 204 | ''' 205 | Table 1 are from newer questions where each line is sep by +--- 206 | Table 2 are ----| 207 | Table 3 are like table 1, but they don't end with a 3rd +---- 208 | ''' 209 | cond1 = '+-' in table_lines[0][0] 210 | table_lines_combined = list(itertools.chain.from_iterable(table_lines)) 211 | count = len([line for line in table_lines_combined if '+-' in line]) 212 | cond2 = count % 3 == 0 213 | if cond1 and cond2: 214 | return 'Table1' 215 | elif not cond1: 216 | return 'Table2' 217 | elif cond1: 218 | return 'Table3' 219 | 220 | def add_filler_col1(self, index, line, is_final=False): 221 | ''' 222 | DB-fiddle does not support text to DDL for tables with 1 column. This adds a filler column for type 1 tables 223 | ''' 224 | if index == 0: 225 | concat = '---------+' 226 | elif index == 1: 227 | concat = ' ignore |' 228 | elif index == 2: 229 | concat = '---------+' 230 | elif is_final: 231 | concat = '---------+' 232 | else: 233 | concat = ' _ |' 234 | return line + concat 235 | 236 | def add_filler_col2(self, index, line): 237 | ''' 238 | DB-fiddle does not support text to DDL for tables with 1 column. This adds a filler column for type 2 tables 239 | ''' 240 | if index == 0: 241 | concat = ' ignore|' 242 | elif index == 1: 243 | concat = '-------|' 244 | else: 245 | concat = ' _ |' 246 | return line + concat 247 | 248 | def add_filler_col3(self, index, line): 249 | ''' 250 | DB-fiddle does not support text to DDL for tables with 1 column. This adds a filler column for type 2 tables 251 | ''' 252 | if index == 0: 253 | concat = '---------+' 254 | elif index == 1: 255 | concat = ' ignore |' 256 | elif index == 2: 257 | concat = '---------+' 258 | else: 259 | concat = ' _ |' 260 | return line + concat 261 | 262 | 263 | def update_date_format(self, line): 264 | ''' 265 | db-fiddle likes dates formatted as Y-m-d (i.e. 2020-5-1), 2 digit padding is optional 266 | ''' 267 | pattern = re.compile(r'(\d{1,2})/(\d{1,2})/(\d{4})') 268 | return re.sub(pattern, r'\g<3>-\g<1>-\g<2>', line) 269 | 270 | def replace_invalid_char_header(self, line): 271 | ''' 272 | for each non-valid char match, returns a space 273 | The inner function adds a space for every match, rather than just one space for all matches 274 | ''' 275 | #valid characters for tables names | valid characters for table row demarcations 276 | pattern = re.compile(r'[^\w\s\|\+\-]+') 277 | def repl(m): 278 | return '_' * len(m.group()) 279 | sub = re.sub(pattern, repl, line) 280 | return sub 281 | 282 | def seperate_tables1(self, table_lines): 283 | ''' 284 | Seperate each table of table type 1 (they contain '+' in the text) into its own item in tables_text 285 | ''' 286 | tables_text, current_table = [], [] 287 | plus_ct = line_i = 0 288 | is_single_col = False 289 | for table_line in table_lines: 290 | for line in table_line: 291 | if line_i == 0: 292 | if line.count('+') == 2: 293 | is_single_col = True 294 | #if header line 295 | if line_i == 1: 296 | line = self.replace_invalid_char_header(line) 297 | if '+-' in line: 298 | plus_ct += 1 299 | if line.count('/') >= 2: 300 | line = self.update_date_format(line) 301 | if is_single_col: 302 | line = self.add_filler_col1(line_i, line, plus_ct == 3) 303 | current_table.append(line) 304 | line_i += 1 305 | #marks the end of the table 306 | if plus_ct == 3: 307 | tables_text.append('\n'.join(current_table)) 308 | current_table = [] 309 | is_single_col = False 310 | plus_ct = line_i = 0 311 | return tables_text 312 | 313 | def seperate_tables2(self, table_lines): 314 | ''' 315 | Seperate each table of table type 2 (they contain '|' in the first line) into its own item in tables_text list 316 | ''' 317 | tables_text, current_table = [], [] 318 | for table_line in table_lines: 319 | is_single_col = False 320 | if table_line[0].count('|') == 2: 321 | is_single_col = True 322 | for line_i, line in enumerate(table_line): 323 | if line_i == 0: 324 | line = self.replace_invalid_char_header(line) 325 | if is_single_col: 326 | line = self.add_filler_col2(line_i, line) 327 | if line.count('/') >= 2: 328 | line = self.update_date_format(line) 329 | current_table.append(line) 330 | tables_text.append('\n'.join(current_table)) 331 | current_table = [] 332 | return tables_text 333 | 334 | def seperate_tables3(self, table_lines): 335 | ''' 336 | Seperate each table of table type 3. Type 3 tables contain '+-' like type 1 tables, but they don't end with a '+-'. The only problem like this is 619 337 | ''' 338 | tables_text, current_table = [], [] 339 | for table_line in table_lines: 340 | is_single_col = False 341 | if table_line[0].count('+') == 2: 342 | is_single_col = True 343 | for line_i, line in enumerate(table_line): 344 | if line_i == 0: 345 | line = self.replace_invalid_char_header(line) 346 | if is_single_col: 347 | line = self.add_filler_col3(line_i, line) 348 | if line.count('/') >= 2: 349 | line = self.update_date_format(line) 350 | current_table.append(line) 351 | tables_text.append('\n'.join(current_table)) 352 | current_table = [] 353 | return tables_text 354 | 355 | def remove_dups(self, l): 356 | ''' 357 | remove dups from list while preserving order (Python 3.6+) 358 | ''' 359 | return list(dict.fromkeys(l)) 360 | 361 | def parse_table_names_by_kword(self, tables_pre): 362 | ''' 363 | Finds table name based on table kword in
tag text only 364 | Thetags contain the actual tables 365 | ''' 366 | try: 367 | #the regex is looking any words preceding the keyword tableand after a newline or start of string 368 | groups = re.findall(r'(\n|^)(\w+)\stable', ''.join(tables_pre)) 369 | return [group[1] for group in groups] 370 | except: 371 | return [] 372 | 373 | def parse_table_names_by_position(self, tables_pre): 374 | ''' 375 | Find table name by position intext only 376 | Thetags contain the actual tables 377 | ''' 378 | #method 2, fromtag, collects the first word above each table line 379 | table_pre = '\n'.join(tables_pre) 380 | try: 381 | #capture words before first table 382 | name_first_position = [re.match(r'([_a-zA-z]+).*\n\+-', table_pre).group(1)] 383 | #capture words between 2 new lines, and next table 384 | names_remaining_position = re.findall(r'\n\n(.*?)\n\+-', table_pre) 385 | names_position = name_first_position + names_remaining_position 386 | return [name.split()[0] for name in names_position] 387 | 388 | except (AttributeError, IndexError): 389 | return [] 390 | 391 | def parse_table_names_by_code_tag(self): 392 | ''' 393 | Find table name bytag within ENTIRE web page 394 | A few early problems like #176 uses this method 395 | ''' 396 | elements = self.driver.find_elements_by_css_selector("code") 397 | element_names = self.remove_dups([element.text for element in elements]) 398 | invalid_names = ['null', 'DIAB1','B', 'delete', 'median'] 399 | names_code = [] 400 | for name in element_names: 401 | if re.match(r'[_a-zA-Z]+', name) and name not in invalid_names: 402 | names_code.append(name) 403 | return names_code 404 | 405 | def parse_table_names_by_bold(self): 406 | ''' 407 | Find table name by tag within ENTIRE web page. This is used only for a few problems like 579,580,585,586 where the table name is next to the word table and bolded. 408 | Further implenetation details: 409 | For the name to be added to the list, it needs to meet multiple specifications. Firstly, the paragraph must contain a bolded word. Secondly, the paragraph must contain the word 'table'. Thirdly, the word preceding table or the word after table must match the bolded word 410 | 411 | ''' 412 | names_bold = [] 413 | try: 414 | paragrahs = self.driver.find_elements_by_css_selector("p") 415 | except: 416 | pass 417 | for paragraph in paragrahs: 418 | #try to find bolded word(s) in paragraph 419 | try: 420 | bolded = paragraph.find_elements_by_css_selector("b") 421 | #See if paragraph has the word table and preceding table name 422 | try: 423 | preceding = re.search(r'(\w+)\s[tT]able', paragraph.text).group(1) 424 | #compare bolded word against the word preceding the keyword table 425 | for bold in bolded: 426 | if preceding == bold.text: 427 | names_bold.append(bold.text) 428 | 429 | #negative lookaround, looks for the word after keyword table and any whitespace, non-ascii characters 430 | after = re.search(r'(?<=table)s?[\s\W]*(\w+)', paragraph.text).group(1) 431 | #preceding and after need seperate for loop because if there's a table kword, there is always a preceding but not always an after 432 | for bold in bolded: 433 | if after == bold.text: 434 | names_bold.append(bold.text) 435 | #no keyword table in paragraph 436 | except: 437 | pass 438 | #no bolded word in paragraph 439 | except: 440 | pass 441 | return names_bold 442 | 443 | def get_closest_names(self, target_len, names_args): 444 | ''' 445 | Returns the names list that has a length closest to the number of tables parsed 446 | This will only be called when the number of table names parsed does not equal to the number of tables parsed 447 | ''' 448 | min_diff = float('inf') 449 | for names in names_args: 450 | diff = abs(target_len - len(names)) 451 | if diff < min_diff: 452 | min_diff = diff 453 | names_final = names 454 | 455 | if min_diff == 0: 456 | return names_final 457 | elif len(names_final) > target_len: 458 | print('CAUTION: Unknown table nams- too many names parsed compared to # of tables') 459 | while len(names_final) != target_len: 460 | names_final.pop() 461 | else: 462 | i = 0 463 | print('CAUTION: Unknown table names- too few names parsed compared to # of tables') 464 | while len(names_final) != target_len: 465 | names_final.append('Unknown{i}'.format(i=i)) 466 | i+=1 467 | return names_final 468 | 469 | def parse_table_names(self, tables_pre, target_len): 470 | ''' 471 | The naming of the tables in leetcode is inconsistent. Below are first different ways to parse the table names. 472 | ''' 473 | 474 | def add_result_tbl_name(names): 475 | if 'Result' not in names and 'result' not in names: 476 | names.append('Result') 477 | return names 478 | 479 | if target_len == 1: 480 | with open("unknown.txt",'a',encoding = 'utf-8') as f: 481 | f.write(f'\ntarget_len only 1, tables_pre: {tables_pre}') 482 | #a list of each list of parsed names 483 | all_names = [] 484 | parse_name_funcs = [self.parse_table_names_by_kword, self.parse_table_names_by_position, self.parse_table_names_by_code_tag, self.parse_table_names_by_bold] 485 | 486 | for parse_names in parse_name_funcs: 487 | try: 488 | names = parse_names(tables_pre) 489 | #some of the functions take in an arg, some don't 490 | except TypeError: 491 | names = parse_names() 492 | names = add_result_tbl_name(self.remove_dups(names)) 493 | if len(names) == target_len: 494 | return names 495 | all_names.append(names) 496 | return self.get_closest_names(target_len, all_names) 497 | 498 | def parse_leetcode_tables(self, leet_win): 499 | ''' 500 | The main parsing function that combines everything together. The tables are always listed under the
tag. Tables_pre includes alll this text. However, it also includes extraneous text. Table_lines removes the extraneous text. Lastly, each of the tables are seperated as an item 501 | ''' 502 | self.driver.switch_to.window(leet_win) 503 | tables_pre = self.parse_table_pre() 504 | table_lines = self.parse_table_lines(tables_pre) 505 | 506 | table_type = self.get_table_type(table_lines) 507 | #seperate tables based on type 508 | if table_type == 'Table1': 509 | tables_text = self.seperate_tables1(table_lines) 510 | elif table_type == 'Table2': 511 | tables_text = self.seperate_tables2(table_lines) 512 | elif table_type == 'Table3': 513 | tables_text = self.seperate_tables3(table_lines) 514 | 515 | table_names = self.parse_table_names(tables_pre, len(tables_text)) 516 | return table_names, tables_text 517 | 518 | def open_db_win(self, url='https://www.db-fiddle.com/'): 519 | self.db_win = self.open_new_win(url) 520 | 521 | def db_fiddle_select_engine(self, db_engine): 522 | self.driver.switch_to.window(self.db_win) 523 | WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.element_to_be_clickable((By.CLASS_NAME, 'ember-power-select-status-icon'))).click() 524 | try: 525 | self.driver.find_elements_by_class_name('ember-power-select-option')[db_engine].click() 526 | #if DB_ENGINE index is invalid, choose the first engine 527 | except IndexError: 528 | print('\nInvalid sql engine selection, changing to mySQL8') 529 | self.driver.find_elements_by_class_name('ember-power-select-option')[0].click() 530 | 531 | def click_query_table(self): 532 | try: 533 | self.driver.switch_to.window(self.db_win) 534 | #Code Mirror lines element must be activated, before textbox element can be sent keys 535 | code_mirror = WebDriverWait(self.driver, self.__WAIT_LONG).until(EC.element_to_be_clickable((By.XPATH, '//*[@id="query"]/div[2]/div[6]/div[1]/div/div/div'))) 536 | code_mirror.click() 537 | except: 538 | pass 539 | 540 | def db_fiddle_query_input(self, table_name): 541 | self.driver.switch_to.window(self.db_win) 542 | self.click_query_table() 543 | textbox = WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.presence_of_element_located((By.XPATH, '//*[@id="query"]/div[2]/div[1]/textarea'))) 544 | query = 'SELECT * FROM {table_name}'.format(table_name=table_name) 545 | textbox.send_keys(query) 546 | 547 | def db_fiddle_table_input(self, table_name, table_text): 548 | self.driver.switch_to.window(self.db_win) 549 | fluent_wait = WebDriverWait(self.driver, self.__WAIT_SHORT, poll_frequency=.5, ignored_exceptions=[ElementNotVisibleException, ElementNotSelectableException]) 550 | fluent_wait.until(EC.element_to_be_clickable((By.XPATH, '//*[@id="schema"]/div[3]/button[1]'))).click() 551 | table_name_input = WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.presence_of_element_located((By.XPATH, "//div[@id='textToDDLModal']//*[starts-with(@class,'modal-body')]//*[starts-with(@id,'ember')]/input"))) 552 | table_name_input.send_keys(table_name) 553 | 554 | table_input = fluent_wait.until(EC.presence_of_element_located((By.XPATH, "//div[@id='textToDDLModal']//*[starts-with(@class,'modal-body')]//*[starts-with(@id,'ember')]/textarea"))) 555 | table_input.send_keys(table_text) 556 | append_button = WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.element_to_be_clickable((By.XPATH, "//div[@id='textToDDLModal']//*[starts-with(@class,'modal-body')]/button[2]"))) 557 | append_button.click() 558 | 559 | def db_fiddle_save(self): 560 | self.driver.switch_to.window(self.db_win) 561 | pre_url = self.driver.current_url 562 | try: 563 | WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.element_to_be_clickable((By.XPATH, '//*[@id="saveButton"]'))).click() 564 | except: 565 | WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.element_to_be_clickable((By.XPATH, '//*[@id="runButton"]'))).click() 566 | #after saving, wait until url has changed to return the saved url 567 | WebDriverWait(self.driver, self.__WAIT_LONG).until_not(EC.url_to_be(pre_url)) 568 | return self.driver.current_url 569 | 570 | def db_fiddle_fork(self): 571 | self.driver.switch_to.window(self.db_win) 572 | pre_url = self.driver.current_url 573 | WebDriverWait(self.driver, self.__WAIT_SHORT).until(EC.element_to_be_clickable((By.ID, 'forkButton'))).click() 574 | #after forking, wait until url has changed to return the saved url 575 | WebDriverWait(self.driver, self.__WAIT_LONG).until_not(EC.url_to_be(pre_url)) 576 | return self.driver.current_url 577 | 578 | def close_question(self, is_save_before_closing): 579 | '''closes the question and returns the end_url for QuestionLog''' 580 | 581 | if len(self.driver.window_handles) == 0: 582 | raise NoSuchWindowException 583 | end_url = None 584 | try: 585 | self.driver.switch_to.window(self.db_win) 586 | if is_save_before_closing: 587 | end_url = self.db_fiddle_save() 588 | else: 589 | end_url = self.driver.current_url 590 | except: 591 | pass 592 | self.close_question_windows() 593 | return end_url 594 | 595 | def open_fork(self, q_num, db_public_url): 596 | self.open_leetcode_win(q_num) 597 | self.open_db_win(db_public_url) 598 | forked_url = self.db_fiddle_fork() 599 | self.click_query_table() 600 | self.driver.switch_to.window(self.leet_win) 601 | return forked_url 602 | 603 | def open_question(self, q_num, db_engine, is_check_new_save_versions, db_prev_url=None): 604 | ''' 605 | Opens the leetcode.jp problem, and a db-fiddle of that problem 606 | ''' 607 | self.open_leetcode_win(q_num) 608 | 609 | #a db fiddle has already been created 610 | if db_prev_url is not None and self.is_valid_save_url(db_prev_url): 611 | if is_check_new_save_versions: 612 | db_start_url = self.open_newest_fiddle_url(db_prev_url) 613 | else: 614 | db_start_url = db_prev_url 615 | self.open_db_win(db_start_url) 616 | 617 | #no db-fiddle has been created yet 618 | else: 619 | self.open_db_win() 620 | self.db_fiddle_select_engine(db_engine) 621 | try: 622 | #parse the sql tables from leetcode.jp 623 | table_names, tables_text = self.TableParser(self.driver).parse_leetcode_tables(self.leet_win) 624 | #couldn't find sql tables to parse 625 | except (NoSuchElementException, IndexError) as e: 626 | return None 627 | #dump parsed tables onto db fiddle 628 | for i, table_text in enumerate(tables_text): 629 | self.db_fiddle_table_input(table_names[i], table_text) 630 | self.db_fiddle_query_input(table_names[0]) 631 | db_start_url = self.db_fiddle_save() 632 | self.click_query_table() 633 | self.driver.switch_to.window(self.leet_win) 634 | return db_start_url 635 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | selenium>=3.141.0 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | version = sys.version_info[:3] 5 | if version < (3, 6, 0): 6 | sys.exit( 'Requires Python >= 3.6.0; ' 7 | 'your version of Python is ' + sys.version ) 8 | 9 | CONFIG_FILE_PATH = 'leetcode_sql_unlocked/src/config.py' 10 | CONFIG_FILE_TEMPLATE = """ 11 | ''' 12 | The following db-fiddle settings can be optionally configured inside leetcode_sql_unlocked/src/config.py: 13 | 14 | DB_ENGINE: User has the following databases to choose from: MYSQL_8, POSTGRES_12, and SQLITE_3_3. Default is MYSQL_8. 15 | 16 | SAVE_BEFORE_CLOSING: If True, before going to the next question or exiting, will save the current fiddle automatically. Note that the fiddle is always saved when first created- this setting is for all proceeding saves. Default is True, meaning the fiddle will be saved. 17 | 18 | CHECK_NEW_SAVE_VERSIONS: If True, will check for any newer versions of the db-fiddle. Default is False. This setting should only be switched to True if user is planning to make changes to their db-fiddles outside of this program. 19 | ''' 20 | 21 | mysql = 'MYSQL_8.0' 22 | postgres = 'POSTGRES_12' 23 | sqlite = 'SQLITE_3_3' 24 | __DB_OPTIONS = {mysql: 0, 25 | postgres: 5, 26 | sqlite: 11} 27 | 28 | cfg = dict( 29 | db_engine = __DB_OPTIONS[mysql], 30 | is_save_before_closing = True, 31 | is_check_new_save_versions = False, 32 | is_fork_public_url = True, 33 | is_preload = True, 34 | n_to_preload = 1, 35 | n_same_level_to_preload = 1 36 | ) 37 | """ 38 | 39 | os.chdir(os.path.dirname(os.path.realpath(__file__))) 40 | if os.path.exists(CONFIG_FILE_PATH): 41 | status = 'updated' 42 | else: 43 | status = 'created' 44 | 45 | 46 | with open(CONFIG_FILE_PATH, "w") as config_file: 47 | config_file.write(CONFIG_FILE_TEMPLATE) 48 | print("Config {} successfully in path:\n{}".format(status, os.path.join(os.getcwd(), CONFIG_FILE_PATH))) 49 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjjchens235/leetcode-sql-unlocked/1734b51a5177a348486b7d26879a7968d8f0121b/test/__init__.py -------------------------------------------------------------------------------- /test/test_leetcode_options.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Unit tests for leetcode_sql_unlocked.py program. 3 | Assumes setup.py has already been run. 4 | Simulates the rest of the program from scratch. 5 | CAUTION: This program will archives logs and delete driver and log folders 6 | Every user option is tested and the state of the log is compared with the state of the QuestionNodes object. 7 | ''' 8 | from datetime import datetime 9 | import unittest 10 | import time 11 | import os 12 | 13 | 14 | parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 15 | sister_dir = os.path.join(parent_dir, 'leetcode_sql_unlocked/') 16 | os.sys.path.insert(0, sister_dir) 17 | import leetcode_sql_unlocked 18 | 19 | class TestLeetcodeOptions(unittest.TestCase): 20 | 21 | @classmethod 22 | def setUpClass(self): 23 | ''' 24 | Tests leetcode_sql_unlocked.py, starting from scratch with no previous logs and drivers 25 | WARNING: deletes driver, and moves all logs to a folder called logs_archive 26 | ''' 27 | #os.chdir(parent_dir) 28 | #self.lc = LeetcodeUnlocked(headless=True) 29 | self.lc = leetcode_sql_unlocked.get_leetcode() 30 | 31 | @classmethod 32 | def options(self, user_input): 33 | self.lc.options(user_input) 34 | 35 | @classmethod 36 | def tearDownClass(self): 37 | self.lc.exit_option() 38 | 39 | def test_invalid_options(self): 40 | 41 | print('\n----------------Running invalid inputs-------') 42 | print('Should print only invalid inputs\n\n') 43 | user_input = 'ahcd' 44 | self.options(user_input) 45 | 46 | user_input = 'zef' 47 | self.options(user_input) 48 | 49 | user_input = '!!!!!!____' 50 | self.options(user_input) 51 | 52 | user_input = '' 53 | self.options(user_input) 54 | 55 | user_input = ' ' 56 | self.options(user_input) 57 | time.sleep(3) 58 | 59 | def test_help_options(self): 60 | print('\n----------------Running help inputs-------') 61 | print('Should print help menu twice\n\n') 62 | user_input = '_help' 63 | self.options(user_input) 64 | 65 | user_input = 'he#' 66 | self.options(user_input) 67 | time.sleep(3) 68 | 69 | def test_disp_options(self): 70 | print('\n----------------Running display inputs-------') 71 | print('Should print different displays\n\n') 72 | 73 | user_input = 'dis$play' 74 | self.options(user_input) 75 | 76 | user_input = 'dg 5' 77 | self.options(user_input) 78 | 79 | user_input = 'de16' 80 | self.options(user_input) 81 | 82 | user_input = 'dmed 30' 83 | self.options(user_input) 84 | 85 | user_input = 'd hdf40' 86 | self.options(user_input) 87 | 88 | user_input = 'd h400' 89 | self.options(user_input) 90 | 91 | user_input = 'd e600' 92 | self.options(user_input) 93 | 94 | def test_load_options(self): 95 | print('\n----------------Running pre-load option inputs-------') 96 | user_inputs = ['load on', 'load off', 'lon', 'loff', 'ldkfdkf'] 97 | for user_input in user_inputs: 98 | self.options(user_input) 99 | 100 | def test_preloading_delay(self): 101 | print('\n-------------Checking preload delays lasting correct duration----') 102 | self.options('load on') 103 | 104 | a = datetime.now() 105 | self.lc.preload_delay(10) 106 | b = datetime.now() 107 | self.assertTrue((b-a).total_seconds() >= 8) 108 | 109 | a = datetime.now() 110 | self.lc.preload_delay(5) 111 | b = datetime.now() 112 | self.assertTrue((b-a).total_seconds() <= 3) 113 | 114 | a = datetime.now() 115 | self.lc.preload_delay(100) 116 | b = datetime.now() 117 | self.assertTrue((b-a).total_seconds() >= 30) 118 | 119 | if __name__ == '__main__': 120 | unittest.main() 121 | 122 | -------------------------------------------------------------------------------- /test/test_leetcode_questions.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Unit tests for leetcode_sql_unlocked.py program. 3 | Assumes setup.py has already been run. 4 | Simulates the rest of the program from scratch. 5 | CAUTION: This program will archives logs and delete driver and log folders 6 | Every user option is tested and the state of the log is compared with the state of the QuestionNodes object. 7 | ''' 8 | from datetime import datetime 9 | import unittest 10 | from unittest.mock import MagicMock 11 | import time 12 | import os 13 | import shutil 14 | 15 | parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 16 | sister_dir = os.path.join(parent_dir, 'leetcode_sql_unlocked/') 17 | os.sys.path.insert(0, sister_dir) 18 | import leetcode_sql_unlocked 19 | 20 | def remove(path): 21 | """ paramcould either be relative or absolute. """ 22 | print(f'Deleting {path}') 23 | if os.path.isfile(path) or os.path.islink(path): 24 | os.remove(path) # remove the file 25 | elif os.path.isdir(path): 26 | shutil.rmtree(path) # remove dir and all contains 27 | else: 28 | raise ValueError("file {} is not a file or dir.".format(path)) 29 | 30 | def move_to_archive(curr_dir, archive_dir): 31 | ''' 32 | Moves existing logs/files to archive directory 33 | ''' 34 | if not os.path.exists(archive_dir): 35 | os.mkdir(archive_dir) 36 | for f in os.listdir(curr_dir): 37 | curr_path = os.path.join(curr_dir, f) 38 | archive_path = os.path.join(archive_dir, datetime.now().strftime('%Y-%m-%d-%H-%M ')+ f) 39 | print(f'Moving {f} to {archive_dir}') 40 | #move files to archive folder 41 | os.rename(curr_path, archive_path) 42 | 43 | class TestLeetcode(unittest.TestCase): 44 | 45 | @classmethod 46 | def setUpClass(self): 47 | ''' 48 | Tests leetcode_sql_unlocked.py, starting from scratch with no previous logs and drivers 49 | WARNING: deletes driver, and moves all logs to a folder called logs_archive 50 | ''' 51 | #os.chdir(sister_dir) 52 | curr_log_dir = os.path.join(sister_dir, 'logs') 53 | curr_driver_dir = os.path.join(sister_dir, 'drivers') 54 | archive_log_dir = os.path.join(sister_dir, 'logs_archive') 55 | if os.path.exists(curr_log_dir): 56 | move_to_archive(curr_log_dir, archive_log_dir) 57 | remove(curr_log_dir) 58 | if os.path.exists(curr_driver_dir): 59 | remove(curr_driver_dir) 60 | self.lc = leetcode_sql_unlocked.get_leetcode(headless=False) 61 | self.lc.options('loff') 62 | self.lc.cfg['is_fork_public_url'] = False 63 | 64 | @classmethod 65 | def tearDownClass(self): 66 | self.lc.exit_option() 67 | 68 | def options_with_join(self, user_input): 69 | self.lc.options(user_input) 70 | self.lc.preloader.thread.join() 71 | 72 | def options(self, user_input): 73 | self.lc.options(user_input) 74 | 75 | def get_q_state(self): 76 | return self.lc.question_log.q_state 77 | 78 | def is_current_question_match(self, compar_num=None): 79 | ''' 80 | Checks if question node current number and q log current number are the same 81 | Optionally, can compare a third expected number, compar_num 82 | ''' 83 | q_nodes_current = self.lc.question_nodes.get_current_num() 84 | q_log_current = self.get_q_state()['current'] 85 | if compar_num is None: 86 | compar_num = q_log_current 87 | return q_nodes_current == q_log_current == compar_num 88 | 89 | def in_url_keys(self, match_nums): 90 | url_nums = self.get_q_state()['url'].keys() 91 | return all(i in url_nums for i in match_nums) 92 | 93 | ''' 94 | def test_stale_file(self): 95 | print('\n\n Testing stale files!') 96 | f = self.lc.question_log.q_state_path 97 | 98 | is_stale = self.lc.is_stale_elements(f, 0) 99 | self.assertFalse(is_stale) 100 | 101 | is_stale = self.lc.is_stale_elements(f, -1) 102 | self.assertTrue(is_stale) 103 | ''' 104 | 105 | def test_q_nodes(self): 106 | #nodes = self.lc.question_nodes.question_nodes 107 | nodes = self.lc.question_nodes._QuestionNodes__question_nodes 108 | k = nodes.keys() 109 | self.assertTrue(len(k) > 120) 110 | self.assertTrue(self.is_current_question_match()) 111 | 112 | def test_q_option(self): 113 | self.assertTrue(self.is_current_question_match(176)) 114 | 115 | url_dic = self.get_q_state()['url'] 116 | user_input = '178' 117 | self.options(user_input) 118 | self.assertTrue(self.is_current_question_match(178)) 119 | url1 = url_dic[178] 120 | time.sleep(30) 121 | 122 | #test going to the same problem 123 | user_input = 'q178' 124 | self.options(user_input) 125 | self.assertTrue(self.is_current_question_match(178)) 126 | 127 | user_input = 'que182' 128 | self.options(user_input) 129 | self.assertTrue(self.is_current_question_match(182)) 130 | 131 | #test the url hasn't changed after 178->182->178 132 | user_input = 'qekekrj178' 133 | self.options(user_input) 134 | self.assertTrue(self.is_current_question_match(178)) 135 | url2 = url_dic[178] 136 | self.assertEqual(url1, url2) 137 | 138 | #should not work 139 | user_input = '5110' 140 | self.assertTrue(self.is_current_question_match(178)) 141 | 142 | #175 can't generate fiddles, so a url should not be created 143 | user_input = '175' 144 | self.options(user_input) 145 | 146 | #The question node will still go to 175, but quesiton log will not because there is no valid url built 147 | self.assertEqual(self.lc.question_nodes.get_current_num(), 175) 148 | self.assertEqual(self.get_q_state()['current'], 178) 149 | self.assertTrue(175 not in url_dic.keys()) 150 | 151 | user_input = '^qs511' 152 | self.options(user_input) 153 | self.assertTrue(self.is_current_question_match(511)) 154 | 155 | #def test_n_option(self): 156 | """ 157 | starting at question 511 158 | """ 159 | url_dic = self.get_q_state()['url'] 160 | url1 = url_dic[511] 161 | 162 | user_input = 'n' 163 | self.options(user_input) 164 | self.assertTrue(self.is_current_question_match(512)) 165 | 166 | user_input = 'nh' 167 | self.options(user_input) 168 | self.assertTrue(self.is_current_question_match(569)) 169 | 170 | user_input = 'q262' 171 | self.options(user_input) 172 | self.assertTrue(self.is_current_question_match(262)) 173 | 174 | user_input = 'next' 175 | self.options(user_input) 176 | self.assertTrue(self.is_current_question_match(511)) 177 | url2 = url_dic[511] 178 | self.assertEqual(url1, url2) 179 | 180 | #test with multiple args, shoudl fail 181 | user_input = 'n ^ m h' 182 | self.options(user_input) 183 | self.assertTrue(self.is_current_question_match(511)) 184 | 185 | #test with invalid arg, should fail 186 | user_input = 'n1' 187 | self.options(user_input) 188 | self.assertTrue(self.is_current_question_match(511)) 189 | 190 | #test with invalid arg, should fail 191 | user_input = 'nf' 192 | self.options(user_input) 193 | self.assertTrue(self.is_current_question_match(511)) 194 | 195 | # test same level using level arg 196 | user_input = 'n e +' 197 | self.options(user_input) 198 | self.assertTrue(self.is_current_question_match(512)) 199 | 200 | #tests that last question to first question works 201 | last_question_num = self.lc.question_nodes.tail.number 202 | user_input = f'q{last_question_num}' 203 | self.options(user_input) 204 | self.assertTrue(self.is_current_question_match(last_question_num)) 205 | #check first question number 206 | first_question_num = self.lc.question_nodes.head.number 207 | self.assertEqual(first_question_num, 175) 208 | user_input = 'n' 209 | self.options(user_input) 210 | #The curent question node pointer will still go to 175, but question log will not because url is not valid 211 | self.assertEqual(self.lc.question_nodes.get_current_num(), 175) 212 | 213 | #this should evaluate to nm (next medium) 214 | user_input = ' )n!@#$$%^& *m (' 215 | self.options(user_input) 216 | self.assertTrue(self.is_current_question_match(177)) 217 | 218 | #------- Preloading testing --------- 219 | self.options('load on') 220 | 221 | #check that both preload questions were loaded, and that current still points to 512 222 | user_input = '512' 223 | self.options_with_join(user_input) 224 | q_num = int(user_input) 225 | self.assertTrue(self.in_url_keys([q_num, 534, 577])) 226 | self.assertTrue(self.is_current_question_match(q_num)) 227 | 228 | #while preload originally includes both 578 and 584, when turning load off, only the current preloaded question (578) should be finished 229 | user_input = '577' 230 | self.options(user_input) 231 | q_num = int(user_input) 232 | self.assertTrue(self.is_current_question_match(q_num)) 233 | self.options('load off') 234 | self.assertTrue(self.in_url_keys([q_num, 578])) 235 | self.assertFalse(self.in_url_keys([584])) 236 | 237 | #Check that after turning load off, that after user goes to 578, nothing is loaded in the background 238 | url_keys = self.get_q_state()['url'].keys() 239 | len1 = len(url_keys) 240 | user_input = '578' 241 | self.options(user_input) 242 | q_num = int(user_input) 243 | self.assertTrue(self.is_current_question_match(q_num)) 244 | len2 = len(url_keys) 245 | self.assertTrue(len1 == len2) 246 | 247 | #Check that after going to 579, url dictionary only increaed by one, similiar to the check above 248 | user_input = '579' 249 | self.options(user_input) 250 | q_num = int(user_input) 251 | self.assertTrue(self.is_current_question_match(q_num)) 252 | len3 = len(url_keys) 253 | self.assertTrue(len1 == len3-1) 254 | 255 | #turn load back on, go to 586, testing if preload is only 1 problem since it happens to be both the next problem, and the next level problem 256 | self.options('load on') 257 | user_input = '586' 258 | self.options_with_join(user_input) 259 | q_num = int(user_input) 260 | self.assertTrue(self.is_current_question_match(q_num)) 261 | self.assertTrue(self.in_url_keys([q_num, 595])) 262 | #dictionary size should increase by 2, for the new current problem, and the one preloaded problem 263 | self.assertTrue(len(url_keys) == len3+2) 264 | 265 | #turn load off to setup just testing preload on its own 266 | self.options('load off') 267 | user_input = '601' 268 | self.options_with_join(user_input) 269 | 270 | #still on 601, preload 3 next, and 3 next same level problems 271 | self.options('load on') 272 | self.lc.preload(3, 3) 273 | #the preloaded problems should match these 6 hardcoded ones 274 | self.assertTrue(self.in_url_keys([602, 603, 607, 615, 618, 1097])) 275 | q_num = int(user_input) 276 | self.assertTrue(self.is_current_question_match(q_num)) 277 | 278 | #make sure regular loading, and preloading are working fine after running preload on its own 279 | user_input = '1097' 280 | self.lc.web_handler.open_question = MagicMock(return_value='MagicMock unit testing') 281 | self.options_with_join(user_input) 282 | self.assertTrue(self.lc.web_handler.open_question.called) 283 | self.assertTrue(self.in_url_keys([1097, 1098, 1127])) 284 | q_num = int(user_input) 285 | self.assertTrue(self.is_current_question_match(q_num)) 286 | 287 | 288 | 289 | #----------- forking ---------------- 290 | self.lc.cfg['is_fork_public_url'] = True 291 | #not forkable because it already exists in q_state 292 | self.assertFalse(self.lc.check_is_forkable((1098))) 293 | 294 | #test 1107 295 | self.assertTrue(self.lc.check_is_forkable(1107)) 296 | #check the public url 297 | self.assertEqual(self.lc.question_log.q_public_urls[1107], 'https://www.db-fiddle.com/f/rpHL2nahbWYo2oe6caR6c5/0') 298 | print('check visually if possible that 1107 was not created from scratch but was loaded from public url') 299 | user_input = '1107' 300 | self.options_with_join(user_input) 301 | #check that 1112 was preloaded as well 302 | self.assertTrue(self.in_url_keys([1107, 1112])) 303 | #make sure public url and preloaded forked url are not the same 304 | self.assertNotEqual(self.get_q_state()['url'][1112], 'https://www.db-fiddle.com/f/eAUrBTavdFTLYM9FbgasKr/0') 305 | 306 | #mock method - check that open_fork() was called 307 | self.lc.web_handler.open_fork = MagicMock(return_value='MagicMock unit testing') 308 | self.lc.web_handler.open_question = MagicMock() 309 | self.options_with_join('1141') 310 | self.assertTrue(self.lc.web_handler.open_fork.called) 311 | self.assertFalse(self.lc.web_handler.open_question.called) 312 | self.assertTrue(self.in_url_keys([1141, 1142])) 313 | 314 | 315 | 316 | if __name__ == '__main__': 317 | unittest.main() 318 | 319 | --------------------------------------------------------------------------------