├── .circleci └── config.yml ├── .dockerignore ├── .flake8 ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── add_artifact_link.yml │ ├── add_label.yml │ ├── autoformat.yml │ ├── ci.yml │ └── upload.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.dev ├── README.md ├── action.yml ├── conftest.py ├── docker-compose.yml ├── kernel_profiler ├── __init__.py ├── entrypoint.py ├── github_action.py ├── html.py ├── markdown.py ├── utils.py └── version.py ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── test_github_action.py ├── test_html.py ├── test_markdown.py ├── test_utils.py └── test_version.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | ubuntu: 5 | machine: 6 | image: ubuntu-1604:202004-01 7 | 8 | commands: 9 | docker_compose_build: 10 | steps: 11 | - run: docker-compose build 12 | 13 | docker_compose_run: 14 | parameters: 15 | command: 16 | type: string 17 | steps: 18 | - run: docker-compose run kernel-profiler << parameters.command >> 19 | 20 | jobs: 21 | build_example: 22 | executor: ubuntu 23 | 24 | steps: 25 | - checkout 26 | 27 | - docker_compose_build 28 | 29 | - run: 30 | name: Make output directory 31 | command: mkdir output 32 | 33 | - docker_compose_run: 34 | command: profile -c m5-forecasting-accuracy -m 1 -o output 35 | 36 | - docker_compose_run: 37 | command: jupyter nbconvert --to html --execute output/m5-forecasting-accuracy.ipynb 38 | 39 | - store_artifacts: 40 | path: output/m5-forecasting-accuracy.html 41 | 42 | workflows: 43 | ci: 44 | jobs: 45 | - build_example: 46 | filters: 47 | branches: 48 | ignore: 49 | - master 50 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | chromedriver 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Labels to apply this PR: 2 | 3 | - [ ] bug 4 | - [ ] enhancement 5 | - [ ] refactor 6 | -------------------------------------------------------------------------------- /.github/workflows/add_artifact_link.yml: -------------------------------------------------------------------------------- 1 | name: Add Artifact Link 2 | on: status 3 | 4 | jobs: 5 | add-artifact-link: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: larsoner/circleci-artifacts-redirector-action@master 9 | with: 10 | repo-token: ${{ secrets.GITHUB_TOKEN }} 11 | artifact-path: 0/output/m5-forecasting-accuracy.html 12 | circleci-jobs: build_example 13 | -------------------------------------------------------------------------------- /.github/workflows/add_label.yml: -------------------------------------------------------------------------------- 1 | name: Add Label 2 | on: 3 | pull_request: 4 | types: [opened, synchronize, edited] 5 | 6 | jobs: 7 | add-label: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/github-script@0.9.0 11 | with: 12 | github-token: ${{secrets.GITHUB_TOKEN}} 13 | script: | 14 | // API doc: https://octokit.github.io/rest.js/v16 15 | 16 | const { owner, repo } = context.repo; 17 | const issue_number = context.issue.number; 18 | const pull_number = context.issue.number; 19 | 20 | // Get a pull request that triggered this action. 21 | const pr = ( 22 | await github.pulls.get({ 23 | owner, 24 | repo, 25 | pull_number, 26 | }) 27 | ).data; 28 | 29 | // Get labels that are available in this repository. 30 | const getName = ({ name }) => name; 31 | const allLabels = ( 32 | await github.issues.listLabelsForRepo({ 33 | owner, 34 | repo, 35 | }) 36 | ).data.map(getName); 37 | 38 | const isAvailable = name => allLabels.includes(name); 39 | 40 | // Get labels attached to the pull request. 41 | const attachedLabels = ( 42 | await github.issues.listLabelsOnIssue({ 43 | owner, 44 | repo, 45 | issue_number, 46 | }) 47 | ).data.map(getName); 48 | 49 | const isAttached = name => attachedLabels.includes(name); 50 | 51 | // Find labels in the pull request description. 52 | const findLabels = (regex, s, labels = []) => { 53 | const res = regex.exec(s); 54 | 55 | if (res) { 56 | const checked = res[1].trim() === "x"; 57 | const name = res[2].trim(); 58 | 59 | return findLabels(regex, s, [{ name, checked }, ...labels]); 60 | } 61 | 62 | return labels; 63 | }; 64 | 65 | const regex = /- \[([ x]*)\] (.+)/gm; 66 | const labels = findLabels(regex, pr.body).filter(({ name }) => isAvailable(name)); 67 | 68 | // Remove unchecked labels. 69 | labels 70 | .filter(({ name, checked }) => !checked && isAttached(name)) 71 | .forEach(async ({ name }) => { 72 | await github.issues.removeLabel({ 73 | owner, 74 | repo, 75 | issue_number, 76 | name, 77 | }); 78 | }); 79 | 80 | // Filter labels to add. 81 | const labelsToAdd = labels 82 | .filter(({ name, checked }) => checked && !isAttached(name)) 83 | .map(getName); 84 | 85 | // `github.issues.addLabels` raises an error when `labels` is empty. 86 | if (labelsToAdd.length > 0) { 87 | await github.issues.addLabels({ 88 | owner, 89 | repo, 90 | issue_number, 91 | labels: labelsToAdd, 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /.github/workflows/autoformat.yml: -------------------------------------------------------------------------------- 1 | name: Autoformat 2 | 3 | on: 4 | issue_comment: 5 | types: 6 | - created 7 | 8 | jobs: 9 | autoformat: 10 | runs-on: ubuntu-latest 11 | if: github.event.issue.pull_request 12 | steps: 13 | - run: echo ${{ github.head_ref }} 14 | - run: echo ${{ github.base_ref }} 15 | - env: 16 | GITHUB_CONTEXT: ${{ toJson(github) }} 17 | run: echo $GITHUB_CONTEXT 18 | 19 | - uses: actions/github-script@v2 20 | id: get-latest-sha 21 | with: 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | script: | 24 | const resp = await github.pulls.listCommits({ 25 | pull_number: context.issue.number, 26 | owner: context.repo.owner, 27 | repo: context.repo.repo, 28 | }); 29 | core.setOutput('sha', resp.data.slice(-1)[0].sha); 30 | 31 | - uses: actions/checkout@v2 32 | with: 33 | ref: ${{ steps.get-latest-sha.outputs.sha }} 34 | - run: | 35 | git status 36 | git remote -v 37 | git branch -a 38 | - run: | 39 | date > test.txt 40 | git config user.name github-actions 41 | git config user.email github-actions@github.com 42 | git add . 43 | git commit -s -m "generated" 44 | git push 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request] 3 | 4 | jobs: 5 | test: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - name: Set up Python 3.7 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: 3.7 15 | 16 | - name: Install dependencies 17 | run: pip install -r requirements.txt -r requirements-dev.txt 18 | 19 | - name: Run tests 20 | run: pytest tests 21 | 22 | - name: Run as CLI 23 | run: | 24 | unset GITHUB_ACTION 25 | pip install -e . 26 | profile -c m5-forecasting-uncertainty -m 1 27 | 28 | - name: Run as GitHub Action 29 | id: make_profile 30 | uses: ./ 31 | with: 32 | comp_slug: m5-forecasting-accuracy 33 | max_num_kernels: 1 34 | out_dir: output 35 | 36 | # Store an output markdown file as an artifact so we can verify it renders properly. 37 | - name: Store output markdown file 38 | uses: actions/upload-artifact@v2 39 | with: 40 | name: ${{ steps.make_profile.outputs.markdown_name }} 41 | path: ${{ steps.make_profile.outputs.markdown_path }} 42 | 43 | lint: 44 | name: Lint 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v2 48 | 49 | - name: Set up Python 3.7 50 | uses: actions/setup-python@v1 51 | with: 52 | python-version: 3.7 53 | 54 | - name: Install dependencies 55 | run: pip install -r requirements-dev.txt 56 | 57 | - name: flake8 58 | run: flake8 . 59 | 60 | - name: black 61 | run: black --check . 62 | -------------------------------------------------------------------------------- /.github/workflows/upload.yml: -------------------------------------------------------------------------------- 1 | name: Upload 2 | on: 3 | push: 4 | branches: 5 | - master 6 | schedule: 7 | # Run this job every 12 hours. 8 | - cron: "0 */12 * * *" 9 | 10 | jobs: 11 | upload: 12 | name: Upload 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | competition: 17 | # id must be less than or equal to 29 characters 18 | # because it will be prefixed with "Top Scoring Kernels: " (21 characters) 19 | # and used as a kernel title (which is restricted to 50 characters). 20 | - slug: m5-forecasting-accuracy 21 | id: m5-forecasting-accuracy 22 | 23 | - slug: m5-forecasting-uncertainty 24 | id: m5-forecasting-uncertainty 25 | 26 | - slug: liverpool-ion-switching 27 | id: liverpool-ion-switching 28 | 29 | - slug: bengaliai-cv19 30 | id: bengaliai-cv19 31 | 32 | - slug: jigsaw-multilingual-toxic-comment-classification 33 | id: jigsaw-multilingual 34 | 35 | - slug: tweet-sentiment-extraction 36 | id: tweet-sentiment-extraction 37 | 38 | - slug: flower-classification-with-tpus 39 | id: flower-classification-tpus 40 | 41 | - slug: trends-assessment-prediction 42 | id: trends-neuroimaging 43 | 44 | - slug: prostate-cancer-grade-assessment 45 | id: panda-challenge 46 | 47 | steps: 48 | - uses: actions/checkout@v2 49 | 50 | - uses: ./ 51 | with: 52 | comp_slug: ${{ matrix.competition.slug }} 53 | out_dir: output 54 | 55 | - uses: harupy/push-kaggle-kernel@master 56 | env: 57 | KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }} 58 | KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }} 59 | with: 60 | id: ${{ secrets.KAGGLE_USERNAME }}/top-scoring-kernels-${{ matrix.competition.id }} 61 | title: "Top Scoring Kernels: ${{ matrix.competition.id }}" 62 | code_file: ./output/${{ matrix.competition.slug }}.ipynb 63 | language: python 64 | kernel_type: notebook 65 | is_private: true 66 | competition_sources: ${{ matrix.competition.slug }} 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | chromedriver 3 | error.html 4 | result.md 5 | .vscode/ 6 | output/ 7 | 8 | # https://github.com/github/gitignore/blob/master/Python.gitignore 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | pip-wheel-metadata/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | cover/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | .pybuilder/ 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ 146 | 147 | # Cython debug symbols 148 | cython_debug/ 149 | 150 | # static files generated from Django application using `collectstatic` 151 | media 152 | static 153 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.6 2 | 3 | ENV PATH="/:${PATH}" 4 | 5 | RUN apt-get update 6 | 7 | # Install Chrome. 8 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add && \ 9 | echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list && \ 10 | cat /etc/apt/sources.list.d/google-chrome.list && \ 11 | apt-get update && \ 12 | apt-get install -y google-chrome-stable 13 | 14 | # Download chromedriver. 15 | RUN CHROME_DRIVER_VERSION=$(curl -sS https://chromedriver.storage.googleapis.com/LATEST_RELEASE) && \ 16 | wget https://chromedriver.storage.googleapis.com/$CHROME_DRIVER_VERSION/chromedriver_linux64.zip && \ 17 | unzip chromedriver_linux64.zip && rm chromedriver_linux64.zip 18 | 19 | COPY . . 20 | 21 | # Install kerne-profiler and enable the command line interface. 22 | RUN pip install -e . 23 | 24 | ENTRYPOINT profile 25 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM python:3.7.6 2 | 3 | WORKDIR /kernel-profiler 4 | 5 | ENV PATH="/:${PATH}" 6 | 7 | RUN apt-get update 8 | 9 | # Install Chrome. 10 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add && \ 11 | echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list && \ 12 | cat /etc/apt/sources.list.d/google-chrome.list && \ 13 | apt-get update && \ 14 | apt-get install -y google-chrome-stable 15 | 16 | # Download chromedriver. 17 | RUN CHROME_DRIVER_VERSION=$(curl -sS https://chromedriver.storage.googleapis.com/LATEST_RELEASE) && \ 18 | wget https://chromedriver.storage.googleapis.com/$CHROME_DRIVER_VERSION/chromedriver_linux64.zip && \ 19 | unzip chromedriver_linux64.zip && rm chromedriver_linux64.zip 20 | 21 | COPY . . 22 | 23 | # Install dependencies. 24 | RUN pip install -r requirements.txt -r requirements-dev.txt 25 | 26 | # Install kerne-profiler and enable the command line interface. 27 | RUN pip install -e . 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kernel Profiler 2 | 3 | ![Upload](https://github.com/harupy/kernel-profiler/workflows/Upload/badge.svg) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | 6 | Profile top scoring public kernels on Kaggle. 7 | 8 | ## How to create a development environment 9 | 10 | ```bash 11 | conda create -n python=3.7 12 | conda activate 13 | 14 | pip install -r requirements.txt -r requirements-dev.txt 15 | ``` 16 | 17 | ## How to run 18 | 19 | ```bash 20 | python entrypoint.py -c titanic 21 | 22 | # or 23 | 24 | pip install -e . 25 | profile -c titanic 26 | ``` 27 | 28 | ## Lint 29 | 30 | ```bash 31 | flake8 . 32 | black --check . 33 | ``` 34 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Profile Kernels 2 | author: harupy 3 | branding: 4 | icon: search 5 | color: green 6 | description: Profile Kernels 7 | inputs: 8 | comp_slug: 9 | description: "Competition slug." 10 | required: true 11 | 12 | max_num_kernels: 13 | description: "How many kernels maximum to profile for each competition." 14 | required: false 15 | default: 20 16 | 17 | out_dir: 18 | description: "Directory to store the output." 19 | required: false 20 | default: output 21 | 22 | outputs: 23 | markdown_path: 24 | description: "Output markdown file path." 25 | 26 | markdown_name: 27 | description: "Output markdown file name." 28 | 29 | notebook_path: 30 | description: "Output notebook file path." 31 | 32 | notebook_name: 33 | description: "Output notebook file name." 34 | 35 | runs: 36 | using: docker 37 | image: Dockerfile 38 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harupy/kernel-profiler/8f17a86c1449cad2d33676c798a948a086bb928d/conftest.py -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | kernel-profiler: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.dev 7 | volumes: 8 | # NOTE: Mounting the entire directory (`./:kernel-profiler`) erases UNKNOWN.egg-info 9 | # that `pip install -e .` generates, and causes an error when running `profile ...`. 10 | # See: https://jbhannah.net/articles/python-docker-disappearing-egg-info 11 | - ./output:/kernel-profiler/output 12 | image: kernel-profiler-image 13 | container_name: kernel-profiler-container 14 | -------------------------------------------------------------------------------- /kernel_profiler/__init__.py: -------------------------------------------------------------------------------- 1 | from kernel_profiler.version import __version__ # NOQA 2 | -------------------------------------------------------------------------------- /kernel_profiler/entrypoint.py: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | import re 4 | from datetime import datetime 5 | 6 | import requests 7 | from bs4 import BeautifulSoup 8 | from selenium import webdriver 9 | from selenium.webdriver.chrome.options import Options 10 | from selenium.webdriver.support.ui import WebDriverWait 11 | from selenium.webdriver.support import expected_conditions as EC 12 | from selenium.webdriver.common.by import By 13 | import pandas as pd 14 | from premailer import transform 15 | from tqdm import tqdm 16 | 17 | from kernel_profiler import markdown as md, html, github_action as ga, utils 18 | 19 | 20 | TOP_URL = "https://www.kaggle.com" 21 | DESCRIPTION = """ 22 | ## My GitHub repository: [harupy/kernel-profiler][kernel-profiler] automatically updates this notebook by using [GitHub Actions][actions] and [Kaggle API][kaggle-api]. Any feedback would be appreciated. 23 | 24 | [kernel-profiler]: https://github.com/harupy/kernel-profiler 25 | [actions]: https://github.com/features/actions 26 | [kaggle-api]: https://github.com/Kaggle/kaggle-api 27 | """.strip() # NOQA 28 | 29 | 30 | def parse_args(): 31 | parser = argparse.ArgumentParser(description="Kernel Profiler") 32 | parser.add_argument( 33 | "-c", "--comp-slug", required=True, help="Competition slug (e.g. titanic)" 34 | ) 35 | 36 | parser.add_argument( 37 | "-m", 38 | "--max-num-kernels", 39 | type=int, 40 | default=20, 41 | help=( 42 | "The maximum number of kernels to profile " 43 | "for each competition (default: 20)" 44 | ), 45 | ) 46 | parser.add_argument( 47 | "-o", 48 | "--out-dir", 49 | default="output", 50 | help='Directory to store the output (default: "output")', 51 | ) 52 | return parser.parse_args() 53 | 54 | 55 | def create_chrome_driver(): 56 | options = Options() 57 | options.add_argument("--headless") 58 | options.add_argument("--no-sandbox") 59 | options.add_argument("--disable-dev-shm-usage") 60 | options.add_argument("window-size=1920x1080") 61 | user_agent = ( 62 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) " 63 | "AppleWebKit/537.36 (KHTML, like Gecko) " 64 | "Chrome/79.0.3945.117 Safari/537.36" 65 | ) 66 | options.add_argument(f"--user-agent={user_agent}") 67 | 68 | if os.path.exists("./chromedriver"): 69 | return webdriver.Chrome("./chromedriver", options=options) 70 | 71 | return webdriver.Chrome(options=options) 72 | 73 | 74 | def make_soup(markup): 75 | return BeautifulSoup(markup, "lxml") 76 | 77 | 78 | def extract_medal_src(soup): 79 | medal = soup.select("img.kernel-list-item__medals") 80 | if len(medal) > 0: 81 | return medal[0].get("src") 82 | 83 | 84 | def extract_kernel_metadata(soup): 85 | medal_src = extract_medal_src(soup) 86 | 87 | return { 88 | "author_name": ( 89 | soup.select("span.tooltip-container")[0].get("data-tooltip").strip() 90 | ), 91 | "author_id": soup.select("a.avatar")[0].get("href").strip("/"), 92 | "thumbnail_src": soup.select("img.avatar__thumbnail")[0].get("src"), 93 | "tier_src": TOP_URL + soup.select("img.avatar__tier")[0].get("src"), 94 | "votes": soup.select("span.vote-button__vote-count")[0].text.strip(), 95 | "comments": ( 96 | soup.select("a.kernel-list-item__info-block--comment")[0].text.strip() 97 | ), 98 | "last_updated": ( 99 | soup.select("div.kernel-list-item__details > span")[0].text.strip() 100 | ), 101 | "best_score": soup.select("div.kernel-list-item__score")[0].text.strip(), 102 | "language": soup.select("span.tooltip-container")[2].text.strip(), 103 | "medal_src": ( 104 | # Replace "notebook" with "discussion" to use a bigger medal image. 105 | TOP_URL + medal_src.replace("notebooks", "discussion") 106 | if medal_src is not None 107 | else "" 108 | ), 109 | } 110 | 111 | 112 | def format_kernel_metadata(meta): 113 | author_link = md.make_link( 114 | meta["author_name"], os.path.join(TOP_URL, meta["author_id"]) 115 | ) 116 | 117 | if meta["medal_src"] != "": 118 | attrs = { 119 | "alt": "medal", 120 | "src": meta["medal_src"], 121 | "align": "left", 122 | } 123 | medal_img = html.make_image_tag(attrs) 124 | else: 125 | medal_img = "-" 126 | 127 | data = [ 128 | ("Author", author_link), 129 | ("Language", meta["language"]), 130 | ("Best Score", meta["best_score"]), 131 | ("Votes", meta["votes"]), 132 | ("Medal", medal_img), 133 | ("Comments", meta["comments"]), 134 | ("Last Updated", meta["last_updated"]), 135 | ] 136 | headers = ["Key", "Value"] 137 | 138 | assert len(data[0]) == len(headers) 139 | 140 | return data, headers 141 | 142 | 143 | def extract_commits(soup): 144 | pattern = re.compile(r"VersionsPaneContent_IdeVersionsTable.+") 145 | rows = soup.find("table", {"class": pattern}).select("tbody > div") 146 | commits = [] 147 | 148 | for row in tqdm(rows): 149 | version = row.select("a:nth-of-type(2)")[0] 150 | committed_at = row.find("span", recursive=False).text.strip() 151 | run_time = row.select("a:nth-of-type(4)")[0].text.strip() 152 | added = row.select("span:nth-of-type(2)")[0].text.strip() 153 | deleted = row.select("span:nth-of-type(3)")[0].text.strip() 154 | href = version.get("href") 155 | 156 | version = version.text.strip() 157 | 158 | if href is None: 159 | continue 160 | 161 | # Ignore failed commits. 162 | status_icon = row.select("a:nth-of-type(1) > svg")[0].get("data-icon") 163 | if status_icon == "times-circle": 164 | continue 165 | 166 | # Extract the public score. 167 | url = TOP_URL + href 168 | resp = requests.get(url) 169 | score = utils.extract_public_score(resp.text) 170 | 171 | # Ignore commits that do not have a score. 172 | if score is None: 173 | continue 174 | 175 | ver_num = utils.extract_int(version) 176 | 177 | commits.append( 178 | ( 179 | ver_num if (ver_num is not None) else version, 180 | score, 181 | committed_at, 182 | utils.round_run_time(run_time), 183 | added, 184 | deleted, 185 | html.make_anchor_tag("Open", {"href": url}), 186 | ) 187 | ) 188 | 189 | headers = [ 190 | "Version", 191 | "Score", 192 | "Committed at", 193 | "Run Time", 194 | "Added", 195 | "Deleted", 196 | "Link", 197 | ] 198 | 199 | assert len(commits[0]) == len(headers) 200 | 201 | return commits, headers 202 | 203 | 204 | def make_profile(kernel_link, thumbnail, commit_table, meta_table): 205 | return f""" 206 |
207 | 208 | # {kernel_link} 209 | 210 | {thumbnail} 211 | 212 | ### Kernel Information 213 | 214 | {meta_table} 215 | 216 | ### Commit History 217 | 218 | The highlighted row(s) corresponds to the best score. 219 | 220 | {commit_table} 221 | """.strip() 222 | 223 | 224 | def extract_kernels(soup): 225 | kernels = [] 226 | 227 | for ker in soup.select("div.block-link--bordered"): 228 | if len(ker.select("div.kernel-list-item__score")) == 0: 229 | continue 230 | 231 | name = ker.select("div.kernel-list-item__name")[0].text 232 | url = TOP_URL + ker.select("a.block-link__anchor")[0].get("href") 233 | kernels.append({"name": name, "url": url, **extract_kernel_metadata(ker)}) 234 | return kernels 235 | 236 | 237 | def highlight_best_score(row, best_score): 238 | should_highlight = float(row["Score"]) == float(best_score) 239 | return [ 240 | ("background-color: #d5fdd5" if should_highlight else "") 241 | for _ in range(len(row)) # len(row) returns the number of columns. 242 | ] 243 | 244 | 245 | def iter_kernels(comp_slug, max_num_kernels): 246 | driver = create_chrome_driver() 247 | 248 | comp_url = f"{TOP_URL}/c/{comp_slug}/notebooks" 249 | 250 | TIMEOUT = 15 # seconds 251 | 252 | # Open the notebooks tab. 253 | driver.get(comp_url) 254 | 255 | # Click `Sort By` select box. 256 | WebDriverWait(driver, TIMEOUT).until( 257 | EC.presence_of_element_located((By.CSS_SELECTOR, "div.Select-value")) 258 | ) 259 | sort_by = driver.find_element_by_css_selector("div.Select-value") 260 | sort_by.click() 261 | 262 | # Select `Best score` option. 263 | WebDriverWait(driver, TIMEOUT).until( 264 | EC.presence_of_element_located((By.CSS_SELECTOR, "div.Select-menu-outer")) 265 | ) 266 | options = driver.find_elements_by_css_selector("div.Select-menu-outer div") 267 | best_score_opt = [opt for opt in options if opt.text == "Best Score"][0] 268 | best_score_opt.click() 269 | 270 | WebDriverWait(driver, TIMEOUT).until( 271 | EC.presence_of_element_located((By.CSS_SELECTOR, "a.block-link__anchor")) 272 | ) 273 | 274 | # Extract kernels. 275 | kernels = extract_kernels(make_soup(driver.page_source)) 276 | num_kernels = min(max_num_kernels, len(kernels)) 277 | 278 | for ker_idx, kernel_meta in enumerate(kernels[:num_kernels]): 279 | print(f"Processing ({ker_idx + 1} / {num_kernels})") 280 | 281 | # Open the kernel. 282 | driver.get(kernel_meta["url"]) 283 | 284 | # Display the commit table. 285 | WebDriverWait(driver, TIMEOUT).until( 286 | EC.presence_of_element_located( 287 | (By.XPATH, "//div[contains(@class, 'VersionsInfoBox')]") 288 | ) 289 | ) 290 | commit_link = driver.find_element_by_xpath( 291 | "//div[contains(@class, 'VersionsInfoBox')]" 292 | ) 293 | commit_link.click() 294 | 295 | WebDriverWait(driver, TIMEOUT).until( 296 | EC.presence_of_element_located( 297 | (By.CSS_SELECTOR, "div.vote-button__voters-modal-title") 298 | ) 299 | ) 300 | 301 | yield driver.page_source, kernel_meta 302 | 303 | driver.quit() 304 | 305 | 306 | def main(): 307 | input_types = { 308 | "comp_slug": str, 309 | "max_num_kernels": int, 310 | "out_dir": str, 311 | } 312 | args = ga.get_action_inputs(input_types) if ga.on_github_action() else parse_args() 313 | 314 | comp_slug = args.comp_slug 315 | max_num_kernels = args.max_num_kernels 316 | out_dir = args.out_dir 317 | 318 | profiles = [] 319 | 320 | for kernel_html, kernel_meta in iter_kernels(comp_slug, max_num_kernels): 321 | soup = make_soup(kernel_html) 322 | 323 | # Make a commit history table. 324 | commits, headers = extract_commits(soup) 325 | 326 | # `premailer.transform` turns CSS blocks into style attributes. 327 | # See: https://github.com/peterbe/premailer 328 | commit_table = transform( 329 | pd.DataFrame(commits, columns=headers) 330 | .style.apply( 331 | highlight_best_score, best_score=kernel_meta["best_score"], axis=1 332 | ) 333 | .hide_index() 334 | .render() 335 | ) 336 | 337 | meta_table = md.make_table(*format_kernel_metadata(kernel_meta)) 338 | kernel_link = md.make_link(kernel_meta["name"], kernel_meta["url"]) 339 | thumbnail = html.make_thumbnail( 340 | kernel_meta["thumbnail_src"], 341 | kernel_meta["tier_src"], 342 | os.path.join(TOP_URL, kernel_meta["author_id"]), 343 | ) 344 | 345 | profiles.append(make_profile(kernel_link, thumbnail, commit_table, meta_table)) 346 | 347 | # Save the output. 348 | os.makedirs(out_dir, exist_ok=True) 349 | md_path = os.path.join(out_dir, f"{comp_slug}.md") 350 | timestamp = "## Last Updated: {}".format( 351 | datetime.utcnow().strftime("%Y/%m/%d %H:%M:%S (UTC)") 352 | ) 353 | with open(md_path, "w") as f: 354 | f.write((2 * "\n").join([DESCRIPTION, timestamp, *profiles])) 355 | 356 | # Convert markdown to notebook. 357 | nb_path = utils.replace_ext(md_path, ".ipynb") 358 | utils.markdown_to_notebook(md_path, nb_path) 359 | 360 | # Set action outputs. 361 | if ga.on_github_action(): 362 | ga.set_action_outputs( 363 | { 364 | "markdown_path": md_path, 365 | "markdown_name": os.path.basename(md_path), 366 | "notebook_path": nb_path, 367 | "notebook_name": os.path.basename(nb_path), 368 | } 369 | ) 370 | 371 | 372 | if __name__ == "__main__": 373 | main() 374 | -------------------------------------------------------------------------------- /kernel_profiler/github_action.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections import namedtuple 3 | 4 | 5 | def on_github_action(): 6 | """ 7 | Examples 8 | -------- 9 | >>> import os 10 | >>> 11 | >>> os.environ["GITHUB_ACTION"] = "true" 12 | >>> on_github_action() 13 | True 14 | 15 | """ 16 | return "GITHUB_ACTION" in os.environ 17 | 18 | 19 | def get_action_input(name): 20 | """ 21 | Examples 22 | -------- 23 | >>> import os 24 | >>> 25 | >>> os.environ["INPUT_A"] = "a" 26 | >>> get_action_input("a") 27 | 'a' 28 | 29 | """ 30 | return os.getenv(f"INPUT_{name.upper()}") 31 | 32 | 33 | def get_action_inputs(input_types): 34 | """ 35 | Examples 36 | -------- 37 | >>> import os 38 | >>> 39 | >>> os.environ["INPUT_STR"] = "a" 40 | >>> os.environ["INPUT_INT"] = "0" 41 | >>> 42 | >>> inputs = get_action_inputs({"str": str, "int": int}) 43 | >>> inputs.str 44 | 'a' 45 | >>> inputs.int 46 | 0 47 | 48 | """ 49 | Args = namedtuple("Args", list(input_types.keys())) 50 | return Args(*[t(get_action_input(k)) for k, t in input_types.items()]) 51 | 52 | 53 | def set_action_output(name, value): 54 | """ 55 | Examples 56 | -------- 57 | >>> set_action_output("a", "b") 58 | ::set-output name=a::b 59 | 60 | References 61 | ---------- 62 | https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-environment-variable # NOQA 63 | 64 | """ 65 | print(f"::set-output name={name}::{value}") 66 | 67 | 68 | def set_action_outputs(outputs): 69 | """ 70 | >>> set_action_outputs({"a": "b", "c": "d"}) 71 | ::set-output name=a::b 72 | ::set-output name=c::d 73 | 74 | """ 75 | for name, value in outputs.items(): 76 | set_action_output(name, value) 77 | -------------------------------------------------------------------------------- /kernel_profiler/html.py: -------------------------------------------------------------------------------- 1 | def format_attributes(attrs): 2 | """ 3 | Examples 4 | -------- 5 | >>> format_attributes({"a": "b", "c": "d"}) 6 | 'a="b" c="d"' 7 | 8 | """ 9 | return " ".join([f'{key}="{val}"' for key, val in attrs.items()]) 10 | 11 | 12 | def make_image_tag(attrs): 13 | """ 14 | Examples 15 | -------- 16 | >>> make_image_tag({"src": "https://src.com/src.png"}) 17 | '' 18 | 19 | """ 20 | return f"" 21 | 22 | 23 | def make_anchor_tag(content, attrs): 24 | """ 25 | Examples 26 | -------- 27 | >>> make_anchor_tag("anchor", {"href": "https://href.com"}) 28 | 'anchor' 29 | 30 | """ 31 | return f"{content}" 32 | 33 | 34 | def make_thumbnail(thumbnail_src, tier_src, author_url): 35 | """ 36 | Examples 37 | -------- 38 | >>> make_thumbnail("thumbnail.com", "tier.com", "author.com") 39 | '\ 40 | \ 41 | \ 42 | ' 43 | 44 | """ 45 | thumbnail = make_image_tag({"src": thumbnail_src, "width": 72}) 46 | tier = make_image_tag({"src": tier_src, "width": 72}) 47 | return make_anchor_tag( 48 | thumbnail + tier, {"href": author_url, "style": "display: inline-block"} 49 | ) 50 | -------------------------------------------------------------------------------- /kernel_profiler/markdown.py: -------------------------------------------------------------------------------- 1 | def make_link(text, url): 2 | """ 3 | Examples 4 | -------- 5 | >>> make_link("foo", "https://foo.com") 6 | '[foo](https://foo.com)' 7 | 8 | """ 9 | return f"[{text}]({url})" 10 | 11 | 12 | def make_row(items): 13 | """ 14 | Examples 15 | -------- 16 | >>> make_row(["a", "b"]) 17 | '|a|b|' 18 | 19 | >>> make_row([0, 1]) 20 | '|0|1|' 21 | 22 | """ 23 | return "|".join(["", *map(str, items), ""]) 24 | 25 | 26 | def make_table(data, headers): 27 | """ 28 | Examples 29 | -------- 30 | >>> print(make_table([("a", "b")], ["x", "y"])) 31 | |x|y| 32 | |:--|:--| 33 | |a|b| 34 | 35 | """ 36 | return "\n".join( 37 | [make_row(headers), make_row([":--"] * len(headers)), *map(make_row, data)] 38 | ) 39 | -------------------------------------------------------------------------------- /kernel_profiler/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import jupytext 5 | 6 | 7 | def replace_ext(path, ext): 8 | """ 9 | Examples 10 | -------- 11 | >>> replace_ext("foo.md", ".ipynb") 12 | 'foo.ipynb' 13 | 14 | >>> replace_ext("foo.md", "ipynb") 15 | 'foo.ipynb' 16 | 17 | """ 18 | if not ext.startswith("."): 19 | ext = "." + ext 20 | root = os.path.splitext(path)[0] 21 | return root + ext 22 | 23 | 24 | def extract_int(text): 25 | """ 26 | Examples 27 | -------- 28 | >>> extract_int("ver 1") 29 | '1' 30 | 31 | >>> extract_int("ver 10") 32 | '10' 33 | 34 | >>> extract_int("ver") is None 35 | True 36 | 37 | """ 38 | m = re.search(r"\d+", text) 39 | if m is not None: 40 | return m.group(0) 41 | 42 | 43 | def extract_public_score(s): 44 | """ 45 | Examples 46 | -------- 47 | >>> extract_public_score('"publicScore":"0.123"') 48 | '0.123' 49 | 50 | >>> extract_public_score("") is None 51 | True 52 | 53 | """ 54 | m = re.search(r'"publicScore":"(.+?)"', s) 55 | if m is not None: 56 | return m.group(1) 57 | 58 | 59 | def extract_best_public_score(s): 60 | """ 61 | Examples 62 | -------- 63 | >>> extract_best_public_score('"bestPublicScore":0.123,') 64 | '0.123' 65 | 66 | >>> extract_best_public_score("") is None 67 | True 68 | 69 | """ 70 | m = re.search(r'"bestPublicScore":([^,]+)', s) 71 | if m is not None: 72 | return m.group(1) 73 | 74 | 75 | def markdown_to_notebook(md_path, nb_path): 76 | """ 77 | Examples 78 | -------- 79 | >>> import os 80 | >>> import tempfile 81 | >>> 82 | >>> with tempfile.TemporaryDirectory() as tmpdir: 83 | ... md_path = os.path.join(tmpdir, "foo.md") 84 | ... nb_path = os.path.join(tmpdir, "foo.ipynb") 85 | ... 86 | ... with open(md_path, "w") as f: 87 | ... _ = f.write("# Title") 88 | ... 89 | ... markdown_to_notebook(md_path, nb_path) 90 | ... os.path.exists(nb_path) 91 | True 92 | 93 | """ 94 | notebook = jupytext.read(md_path, fmt="md") 95 | jupytext.write(notebook, nb_path) 96 | 97 | 98 | def round_run_time(run_time_str): 99 | """ 100 | Examples 101 | -------- 102 | >>> round_run_time("59s") 103 | '59.0 s' 104 | 105 | >>> round_run_time("60s") 106 | '1.0 m' 107 | 108 | >>> round_run_time("3599s") 109 | '60.0 m' 110 | 111 | >>> round_run_time("3600s") 112 | '1.0 h' 113 | 114 | """ 115 | run_time = float(run_time_str[:-1]) 116 | 117 | if run_time < 60: 118 | return f"{run_time} s" 119 | elif run_time >= 60 and run_time < 3600: 120 | return f"{round(run_time / 60, 1)} m" 121 | else: 122 | return f"{round(run_time / 3600, 1)} h" 123 | -------------------------------------------------------------------------------- /kernel_profiler/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1" 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --verbose --color=yes --durations=10 --doctest-modules kernel_profiler 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8==3.7.9 2 | black==19.10b0 3 | pytest==5.4.1 4 | jupyter==1.0.0 5 | nbconvert==5.6.1 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Scraping. 2 | requests==2.23.0 3 | selenium==3.141.0 4 | beautifulsoup4==4.8.2 5 | lxml==4.5.0 6 | 7 | # Highlight the best score. 8 | pandas==1.0.3 9 | Jinja2==2.11.2 10 | premailer==3.6.1 11 | 12 | # Convert markdown files to notebooks. 13 | jupytext==1.4.0 14 | 15 | # Display progress. 16 | tqdm==4.43.0 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | 5 | ROOT = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | 8 | # Use README.md as a long description. 9 | def get_long_description() -> str: 10 | with open(os.path.join(ROOT, "README.md"), encoding="utf-8") as f: 11 | return f.read() 12 | 13 | 14 | def get_install_requires(): 15 | with open(os.path.join(ROOT, "requirements.txt"), encoding="utf-8") as f: 16 | return [ 17 | l.strip() 18 | for l in f.readlines() 19 | # Ignore comments and blank lines 20 | if not (l.startswith("#") or (l.strip() == "")) 21 | ] 22 | 23 | 24 | setup( 25 | install_requires=get_install_requires(), 26 | packages=find_packages(), 27 | entry_points={"console_scripts": ["profile = kernel_profiler.entrypoint:main"]}, 28 | ) 29 | -------------------------------------------------------------------------------- /tests/test_github_action.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | 4 | from kernel_profiler import github_action as ga 5 | 6 | 7 | @contextlib.contextmanager 8 | def set_env(environ): 9 | old_environ = dict(os.environ) 10 | os.environ.update(environ) 11 | try: 12 | yield 13 | finally: 14 | os.environ.clear() 15 | os.environ.update(old_environ) 16 | 17 | 18 | def test_on_github_action(): 19 | with set_env({"GITHUB_ACTION": "true"}): 20 | assert ga.on_github_action() 21 | 22 | 23 | def test_get_action_input(): 24 | env = { 25 | "INPUT_A": "a", 26 | } 27 | 28 | with set_env(env): 29 | assert ga.get_action_input("a") == "a" 30 | 31 | 32 | def test_get_action_inputs(): 33 | env = { 34 | "INPUT_STR": "x", 35 | "INPUT_INT": "0", 36 | "INPUT_FLOAT": "0.1", 37 | } 38 | 39 | input_types = { 40 | "str": str, 41 | "int": int, 42 | "float": float, 43 | } 44 | 45 | with set_env(env): 46 | args = ga.get_action_inputs(input_types) 47 | assert args.str == "x" 48 | assert args.int == 0 49 | assert args.float == 0.1 50 | 51 | 52 | def test_set_action_output(capsys): 53 | ga.set_action_output("a", "b") 54 | captured = capsys.readouterr() 55 | assert captured.out == "::set-output name=a::b\n" 56 | 57 | 58 | def test_set_action_outputs(capsys): 59 | ga.set_action_outputs({"a": "b", "c": "d"}) 60 | captured = capsys.readouterr() 61 | assert captured.out == "::set-output name=a::b\n::set-output name=c::d\n" 62 | -------------------------------------------------------------------------------- /tests/test_html.py: -------------------------------------------------------------------------------- 1 | from kernel_profiler import html 2 | import re 3 | 4 | 5 | def test_format_attributes(): 6 | assert html.format_attributes({"a": "b", "c": "d"}) == 'a="b" c="d"' 7 | assert html.format_attributes({"a": 0, "c": 1}) == 'a="0" c="1"' 8 | 9 | 10 | def test_make_img_tag(): 11 | src = "https://src.com/src.png" 12 | assert html.make_image_tag({"src": src}) == f'' 13 | 14 | 15 | def test_make_anchor_tag(): 16 | href = "https://src.com/src.png" 17 | assert html.make_anchor_tag("a", {"href": href}) == f'a' 18 | 19 | 20 | def test_make_thumbnail(): 21 | template = "https://src.com/{}.png" 22 | thumbnail_src = template.format("thumbnail") 23 | tier_src = template.format("tier") 24 | author_url = template.format("author") 25 | 26 | actual = html.make_thumbnail(thumbnail_src, tier_src, author_url) 27 | expected = """ 28 | 29 | 30 | 31 | 32 | """.format( 33 | author_url, thumbnail_src, tier_src 34 | ) 35 | expected = re.sub(r"^\s+|\n", "", expected, flags=re.MULTILINE) 36 | assert actual == expected 37 | -------------------------------------------------------------------------------- /tests/test_markdown.py: -------------------------------------------------------------------------------- 1 | from kernel_profiler import markdown as md 2 | 3 | 4 | def test_make_row(): 5 | assert md.make_row(["a", "b"]) == "|a|b|" 6 | 7 | 8 | def test_make_link(): 9 | assert md.make_link("text", "https://url.com") 10 | 11 | 12 | def test_make_table(): 13 | data = [(1, 2), (3, 4)] 14 | headers = ["a", "b"] 15 | 16 | actual = md.make_table(data, headers) 17 | expected = "\n".join(["|a|b|", "|:--|:--|", "|1|2|", "|3|4|"]) 18 | assert actual == expected 19 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from kernel_profiler import utils 4 | 5 | 6 | def test_replace_ext(): 7 | assert utils.replace_ext("a.txt", ".png") == "a.png" 8 | assert utils.replace_ext("a.txt", "png") == "a.png" 9 | 10 | 11 | def test_extract_int(): 12 | assert utils.extract_int("1 day") == "1" 13 | assert utils.extract_int("10 days") == "10" 14 | assert utils.extract_int("x") is None 15 | 16 | 17 | def test_extract_public_score(): 18 | assert utils.extract_public_score('"publicScore":"0.123"') == "0.123" 19 | 20 | 21 | def test_extract_best_public_score(): 22 | assert utils.extract_best_public_score('"bestPublicScore":0.123,') == "0.123" 23 | 24 | 25 | def test_markdown_to_notebook(tmpdir): 26 | md_path = os.path.join(tmpdir, "test.md") 27 | nb_path = os.path.join(tmpdir, "test.ipynb") 28 | 29 | with open(md_path, "w") as f: 30 | f.write("# Test") 31 | 32 | utils.markdown_to_notebook(md_path, nb_path) 33 | assert os.path.exists(nb_path) 34 | 35 | 36 | def test_round_run_time(): 37 | assert utils.round_run_time("59s") == "59.0 s" 38 | assert utils.round_run_time("60s") == "1.0 m" 39 | assert utils.round_run_time("3599s") == "60.0 m" 40 | assert utils.round_run_time("3600s") == "1.0 h" 41 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | import kernel_profiler 2 | 3 | 4 | def test_version_exists(): 5 | assert hasattr(kernel_profiler, "__version__") 6 | --------------------------------------------------------------------------------