├── .github ├── semantic_release │ ├── package-lock.json │ ├── package.json │ └── release_notes.hbs └── workflows │ ├── lint.yaml │ ├── matchers │ ├── flake8.json │ ├── mypy.json │ └── python.json │ ├── publish.yaml │ ├── release.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .releaserc.js ├── LICENSE ├── README.md ├── browsr ├── __about__.py ├── __init__.py ├── __main__.py ├── base.py ├── browsr.css ├── browsr.py ├── cli.py ├── config.py ├── exceptions.py ├── screens │ ├── __init__.py │ └── code_browser.py ├── utils.py └── widgets │ ├── __init__.py │ ├── code_browser.py │ ├── confirmation.py │ ├── double_click_directory_tree.py │ ├── files.py │ ├── universal_directory_tree.py │ ├── vim.py │ └── windows.py ├── docs ├── _static │ ├── browsr.png │ ├── browsr_no_label.png │ ├── screenshot_datatable.png │ ├── screenshot_markdown.png │ ├── screenshot_mona_lisa.png │ └── screenshot_utils.png ├── cli.md ├── contributing.md ├── gen_ref_pages.py └── index.md ├── mkdocs.yaml ├── pyproject.toml ├── requirements.txt ├── requirements ├── requirements-all.py3.10.txt ├── requirements-all.py3.11.txt ├── requirements-all.py3.12.txt ├── requirements-all.py3.8.txt ├── requirements-all.py3.9.txt ├── requirements-docs.txt ├── requirements-lint.txt └── requirements-test.txt └── tests ├── __init__.py ├── __snapshots__ └── test_screenshots.ambr ├── cassettes ├── test_github_screenshot.yaml ├── test_github_screenshot_license.yaml ├── test_mkdocs_screenshot.yaml └── test_textual_app_context_path_github.yaml ├── conftest.py ├── debug_app.py ├── test_browsr.py ├── test_cli.py ├── test_config.py └── test_screenshots.py /.github/semantic_release/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@semantic-release/exec": "^6.0.3", 4 | "@semantic-release/git": "^10.0.1", 5 | "@semantic-release/github": "^8.0.7", 6 | "semantic-release": "^21.0.1", 7 | "semantic-release-gitmoji": "^1.6.4" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/semantic_release/release_notes.hbs: -------------------------------------------------------------------------------- 1 | {{#if compareUrl}} 2 | # [v{{nextRelease.version}}]({{compareUrl}}) ({{datetime "UTC:yyyy-mm-dd"}}) 3 | {{else}} 4 | # v{{nextRelease.version}} ({{datetime "UTC:yyyy-mm-dd"}}) 5 | {{/if}} 6 | 7 | {{#with commits}} 8 | 9 | {{#if boom}} 10 | ## 💥 Breaking Changes 11 | {{#each boom}} 12 | - {{> commitTemplate}} 13 | {{/each}} 14 | {{/if}} 15 | 16 | {{#if sparkles}} 17 | ## ✨ New Features 18 | {{#each sparkles}} 19 | - {{> commitTemplate}} 20 | {{/each}} 21 | {{/if}} 22 | 23 | {{#if bug}} 24 | ## 🐛 Bug Fixes 25 | {{#each bug}} 26 | - {{> commitTemplate}} 27 | {{/each}} 28 | {{/if}} 29 | 30 | {{#if ambulance}} 31 | ## 🚑 Critical Hotfixes 32 | {{#each ambulance}} 33 | - {{> commitTemplate}} 34 | {{/each}} 35 | {{/if}} 36 | 37 | {{#if lock}} 38 | ## 🔒 Security Issues 39 | {{#each lock}} 40 | - {{> commitTemplate}} 41 | {{/each}} 42 | {{/if}} 43 | 44 | {{#if arrow_up}} 45 | ## ⬆️ Dependency Upgrade 46 | {{#each arrow_up}} 47 | - {{> commitTemplate}} 48 | {{/each}} 49 | {{/if}} 50 | 51 | {{#if memo}} 52 | ## 📝️ Documentation 53 | {{#each memo}} 54 | - {{> commitTemplate}} 55 | {{/each}} 56 | {{/if}} 57 | 58 | {{#if construction_worker}} 59 | ## 👷 CI/CD 60 | {{#each construction_worker}} 61 | - {{> commitTemplate}} 62 | {{/each}} 63 | {{/if}} 64 | 65 | {{#if white_check_mark}} 66 | ## 🧪️ Add / Update / Pass Tests 67 | {{#each white_check_mark}} 68 | - {{> commitTemplate}} 69 | {{/each}} 70 | {{/if}} 71 | 72 | {{#if test_tube}} 73 | ## 🧪️ Add Failing Tests 74 | {{#each test_tube}} 75 | - {{> commitTemplate}} 76 | {{/each}} 77 | {{/if}} 78 | 79 | {{#if recycle}} 80 | ## ♻️Refactoring 81 | {{#each recycle}} 82 | - {{> commitTemplate}} 83 | {{/each}} 84 | {{/if}} 85 | 86 | {{#if truck}} 87 | ## 🚚 Moving/Renaming 88 | {{#each truck}} 89 | - {{> commitTemplate}} 90 | {{/each}} 91 | {{/if}} 92 | 93 | {{/with}} 94 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: ["**"] 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up Github Workspace 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - name: Set up Python Environment 3.11 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.11" 19 | - name: Install Hatch 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install -q hatch 23 | hatch env create 24 | hatch --version 25 | - name: Lint 26 | id: lint 27 | continue-on-error: true 28 | run: | 29 | echo "::add-matcher::.github/workflows/matchers/flake8.json" 30 | hatch run lint:style 31 | echo "::remove-matcher owner=flake8::" 32 | - name: Code Checker 33 | id: check 34 | continue-on-error: true 35 | run: | 36 | echo "::add-matcher::.github/workflows/matchers/mypy.json" 37 | hatch run lint:typing 38 | echo "::remove-matcher owner=mypy::" 39 | - name: Raise Errors For Linting Failures 40 | if: | 41 | steps.lint.outcome != 'success' || 42 | steps.check.outcome != 'success' 43 | run: | 44 | echo "Lint: ${{ steps.lint.outcome }}" 45 | echo "Check: ${{ steps.check.outcome }}" 46 | exit 1 47 | -------------------------------------------------------------------------------- /.github/workflows/matchers/flake8.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "flake8", 5 | "pattern": [ 6 | { 7 | "regexp": "^(.*?):(\\d+):(\\d+): (.*)$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4 12 | } 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/matchers/mypy.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "mypy", 5 | "pattern": [ 6 | { 7 | "regexp": "^(.+):(\\d+):\\s(error|warning|note):\\s(.+)$", 8 | "file": 1, 9 | "line": 2, 10 | "severity": 3, 11 | "message": 4 12 | } 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/matchers/python.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "python", 5 | "pattern": [ 6 | { 7 | "regexp": "^\\s*File\\s\\\"(.*)\\\",\\sline\\s(\\d+),\\sin\\s(.*)$", 8 | "file": 1, 9 | "line": 2 10 | }, 11 | { 12 | "regexp": "^\\s*raise\\s(.*)\\(\\'(.*)\\'\\)$", 13 | "message": 2 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publishing 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | pypi-publish: 10 | name: PyPI 11 | if: github.repository_owner == 'juftin' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repository 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 2 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.11" 22 | - name: Install Hatch 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install -q hatch 26 | hatch env create 27 | hatch --version 28 | - name: Build package 29 | run: | 30 | hatch build 31 | - name: Publish package on PyPI 32 | uses: pypa/gh-action-pypi-publish@release/v1 33 | with: 34 | user: __token__ 35 | password: ${{ secrets.PYPI_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: [main, next, beta, alpha, "*.x"] 5 | jobs: 6 | release: 7 | name: Release 8 | if: github.repository_owner == 'juftin' 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | issues: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | persist-credentials: false 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.11" 24 | - name: Install Hatch 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install -q hatch 28 | hatch env create 29 | hatch --version 30 | - name: Release 31 | run: hatch run gen:release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 34 | GIT_AUTHOR_NAME: github-actions[bot] 35 | GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com 36 | GIT_COMMITTER_NAME: github-actions[bot] 37 | GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com 38 | 39 | github-pages-publish: 40 | runs-on: ubuntu-latest 41 | needs: release 42 | if: github.ref == 'refs/heads/main' && github.repository_owner == 'juftin' 43 | permissions: 44 | pages: write 45 | id-token: write 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | steps: 50 | - name: Checkout Latest Changes 51 | uses: actions/checkout@v4 52 | with: 53 | ref: ${{ github.ref }} 54 | fetch-depth: 0 55 | - name: Set up Python Environment 56 | uses: actions/setup-python@v5 57 | with: 58 | python-version: "3.11" 59 | - name: Install Hatch 60 | run: | 61 | python -m pip install --upgrade pip wheel 62 | python -m pip install -q hatch pre-commit 63 | hatch --version 64 | - name: Create Virtual Environment 65 | run: hatch env create docs 66 | - name: Build Site 67 | run: hatch run docs:build 68 | - name: Setup GitHub Pages 69 | uses: actions/configure-pages@v4 70 | - name: Upload Artifact 71 | uses: actions/upload-pages-artifact@v3 72 | with: 73 | path: site/ 74 | - name: Deploy to GitHub Pages 75 | id: deployment 76 | uses: actions/deploy-pages@v4 77 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - browsr/** 9 | - pyproject.toml 10 | - .github/workflows/tests.yaml 11 | pull_request: 12 | branches: ["**"] 13 | paths: 14 | - browsr/** 15 | - pyproject.toml 16 | - .github/workflows/tests.yaml 17 | schedule: 18 | - cron: 0 12 1 * * 19 | jobs: 20 | test-suite: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: true 24 | matrix: 25 | include: 26 | - { name: Python 3.12, python: "3.12" } 27 | - { name: Python 3.11, python: "3.11" } 28 | - { name: Python 3.10, python: "3.10" } 29 | - { name: Python 3.9, python: "3.9" } 30 | - { name: Python 3.8, python: "3.8" } 31 | steps: 32 | - name: Set up Github Workspace 33 | uses: actions/checkout@v4 34 | with: 35 | fetch-depth: 0 36 | - name: Set up Python Environment ${{ matrix.python }} 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python }} 40 | - name: Install Hatch 41 | run: | 42 | python -m pip install --upgrade pip 43 | python -m pip install -q hatch 44 | hatch --version 45 | - name: Test Suite 46 | run: | 47 | echo "::add-matcher::.github/workflows/matchers/python.json" 48 | hatch run +py="${{ matrix.python }}" all:cov 49 | echo "::remove-matcher owner=python::" 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | snapshot_report.html 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # ruff 148 | .ruff_cache/ 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # Semantic Release 160 | .github/semantic_release/node_modules/ 161 | 162 | # JetBrains IDEs 163 | .idea/ 164 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [commit] 2 | fail_fast: false 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.4.0 7 | hooks: 8 | - id: trailing-whitespace 9 | exclude: '\.ambr$' 10 | - id: end-of-file-fixer 11 | - id: check-yaml 12 | - id: check-ast 13 | - id: check-docstring-first 14 | - id: check-merge-conflict 15 | - id: mixed-line-ending 16 | 17 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 18 | rev: v2.8.0 19 | hooks: 20 | - id: pretty-format-toml 21 | args: [--autofix] 22 | 23 | - repo: https://github.com/pre-commit/mirrors-prettier 24 | rev: v3.0.0-alpha.9-for-vscode 25 | hooks: 26 | - id: prettier 27 | args: [--print-width=88, --tab-width=4] 28 | exclude: | 29 | (?x)( 30 | docs/| 31 | PULL_REQUEST_TEMPLATE.md| 32 | .github/semantic_release/release_notes.hbs 33 | ) 34 | 35 | - repo: local 36 | hooks: 37 | - id: format 38 | name: format 39 | description: Runs Code Auto-Formatters 40 | entry: hatch run lint:fmt 41 | language: system 42 | pass_filenames: false 43 | - id: lint 44 | name: lint 45 | description: Runs Code Linters 46 | entry: hatch run lint:style 47 | language: system 48 | pass_filenames: false 49 | require_serial: false 50 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | 4 | const semantic_release_dir = path.resolve(__dirname, ".github/semantic_release"); 5 | const release_note_path = path.join(semantic_release_dir, "release_notes.hbs"); 6 | const release_note_template = fs.readFileSync(release_note_path, "utf-8"); 7 | 8 | module.exports = { 9 | branches: [ 10 | "main", 11 | "master", 12 | "next", 13 | "next-major", 14 | "+([0-9])?(.{+([0-9]),x}).x", 15 | { 16 | name: "beta", 17 | prerelease: true, 18 | }, 19 | { 20 | name: "alpha", 21 | prerelease: true, 22 | }, 23 | ], 24 | plugins: [ 25 | [ 26 | "semantic-release-gitmoji", 27 | { 28 | releaseNotes: { 29 | template: release_note_template, 30 | }, 31 | }, 32 | ], 33 | [ 34 | "@semantic-release/exec", 35 | { 36 | prepareCmd: "hatch version ${nextRelease.version} && hatch build", 37 | }, 38 | ], 39 | [ 40 | "@semantic-release/git", 41 | { 42 | assets: ["pyproject.toml", "*/__about__.py"], 43 | message: 44 | "🔖 browsr ${nextRelease.version}\n\n${nextRelease.notes}\n[skip ci]", 45 | }, 46 | ], 47 | [ 48 | "@semantic-release/github", 49 | { 50 | assets: [ 51 | { 52 | path: "dist/*.whl", 53 | }, 54 | { 55 | path: "dist/*.tar.gz", 56 | }, 57 | ], 58 | }, 59 | ], 60 | ], 61 | }; 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present Justin Flannery 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | browsr 4 | 5 |
6 | 7 |

8 | a pleasant file explorer in your terminal supporting all filesystems 9 |

10 | 11 |

12 | PyPI 13 | PyPI - Python Version 14 | GitHub License 15 | docs 16 | Testing Status 17 | Hatch project 18 | Ruff 19 | pre-commit 20 | semantic-release 21 | Gitmoji 22 |

23 | 24 | **`browsr`** 🗂️ is a pleasant **file explorer** in your terminal. It's a command line **TUI** 25 | (text-based user interface) application that empowers you to browse the contents of local 26 | and remote filesystems with your keyboard or mouse. 27 | 28 | You can quickly navigate through directories and peek at files whether they're hosted **locally**, 29 | in **GitHub**, over **SSH**, in **AWS S3**, **Google Cloud Storage**, or **Azure Blob Storage**. View code files 30 | with syntax highlighting, format JSON files, render images, convert data files to navigable 31 | datatables, and more. 32 | 33 | ![](https://raw.githubusercontent.com/juftin/browsr/main/docs/_static/screenshot_utils.png) 34 | 35 |
36 | Screenshots 37 | 38 | 39 |
40 | Image 2 41 | Image 3 42 | Image 4 43 |
44 | 45 | 46 |
47 | 48 |
49 | Screen Recording 50 | 51 | https://user-images.githubusercontent.com/49741340/238535232-459847af-a15c-4d9b-91ac-fab9958bc74f.mp4 52 | 53 |
54 | 55 | ## Installation 56 | 57 | It's recommended to use [pipx](https://pypa.github.io/pipx/) instead of pip. `pipx` installs the package in 58 | an isolated environment and makes it available everywhere. If you'd like to use `pip` instead, just replace `pipx` 59 | with `pip` in the below command. 60 | 61 | ```shell 62 | pipx install browsr 63 | ``` 64 | 65 | ### Extra Installation 66 | 67 | If you're looking to use **`browsr`** on remote file systems, like GitHub or AWS S3, you'll need to install the `remote` extra. 68 | If you'd like to browse parquet / feather files, you'll need to install the `data` extra. Or, even simpler, 69 | you can install the `all` extra to get all the extras. 70 | 71 | ```shell 72 | pipx install "browsr[all]" 73 | ``` 74 | 75 | ## Usage 76 | 77 | Simply give **`browsr`** a path to a local or remote file / directory. 78 | [Check out the Documentation](https://juftin.com/browsr/) for more information 79 | about the file systems supported. 80 | 81 | ### Local 82 | 83 | ```shell 84 | browsr ~/Downloads/ 85 | ``` 86 | 87 | ### GitHub 88 | 89 | ``` 90 | browsr github://juftin:browsr 91 | ``` 92 | 93 | ``` 94 | export GITHUB_TOKEN="ghp_1234567890" 95 | browsr github://juftin:browsr-private@main 96 | ``` 97 | 98 | ### Cloud 99 | 100 | ```shell 101 | browsr s3://my-bucket 102 | ``` 103 | 104 | \*\* _Currently AWS S3, Google Cloud Storage, and Azure Blob Storage / Data Lake are supported._ 105 | 106 | ### SSH / SFTP 107 | 108 | ```shell 109 | browsr ssh://username@example.com:22 110 | ``` 111 | 112 | ## License 113 | 114 | **`browsr`** is distributed under the terms of the [MIT license](LICENSE). 115 | -------------------------------------------------------------------------------- /browsr/__about__.py: -------------------------------------------------------------------------------- 1 | """ 2 | `browsr` version file. 3 | """ 4 | 5 | __author__ = "Justin Flannery" 6 | __email__ = "justin.flannery@juftin.com" 7 | __application__ = "browsr" 8 | __version__ = "1.21.0" 9 | -------------------------------------------------------------------------------- /browsr/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | browsr 3 | """ 4 | -------------------------------------------------------------------------------- /browsr/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | browsr __main__ hook 3 | """ 4 | 5 | from browsr.cli import browsr 6 | 7 | if __name__ == "__main__": 8 | browsr() 9 | -------------------------------------------------------------------------------- /browsr/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extension Classes 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import os 8 | import pathlib 9 | from dataclasses import dataclass, field 10 | from typing import Any, ClassVar 11 | 12 | from textual.app import App 13 | from textual.binding import Binding 14 | from textual.dom import DOMNode 15 | from textual_universal_directorytree import UPath 16 | 17 | from browsr.utils import handle_github_url 18 | 19 | 20 | @dataclass 21 | class TextualAppContext: 22 | """ 23 | App Context Object 24 | """ 25 | 26 | file_path: str = field(default_factory=os.getcwd) 27 | config: dict[str, Any] | None = None 28 | debug: bool = False 29 | max_file_size: int = 20 30 | max_lines: int = 1000 31 | kwargs: dict[str, Any] | None = None 32 | 33 | @property 34 | def path(self) -> UPath: 35 | """ 36 | Resolve `file_path` to a UPath object 37 | """ 38 | if "github" in str(self.file_path).lower(): 39 | file_path = str(self.file_path) 40 | file_path = file_path.lstrip("https://") # noqa: B005 41 | file_path = file_path.lstrip("http://") # noqa: B005 42 | file_path = file_path.lstrip("www.") # noqa: B005 43 | if file_path.endswith(".git"): 44 | file_path = file_path[:-4] 45 | file_path = handle_github_url(url=str(file_path)) 46 | self.file_path = file_path 47 | if str(self.file_path).endswith("/") and len(str(self.file_path)) > 1: 48 | self.file_path = str(self.file_path)[:-1] 49 | storage_options = self.kwargs or {} 50 | if not self.file_path: 51 | return pathlib.Path.cwd().resolve() 52 | else: 53 | path = UPath(self.file_path, **storage_options) 54 | return path.resolve() 55 | 56 | 57 | class SortedBindingsApp(App[str]): 58 | """ 59 | Textual App with Sorted Bindings 60 | """ 61 | 62 | BINDING_WEIGHTS: ClassVar[dict[str, int]] = {} 63 | 64 | @property 65 | def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: 66 | """ 67 | Return the namespace bindings, optionally sorted by weight 68 | 69 | Bindings are currently returned as they're rendered in the 70 | current namespace (screen). This method can be overridden to 71 | return bindings in a specific order. 72 | 73 | Rules: 74 | - Binding weights must be greater than 0 and less than 10000 75 | - If a binding is not in the BINDING_WEIGHTS dict, it will be 76 | given a weight of 500 + its current position in the namespace. 77 | - Specified weights cannot overlap with the default weights (stay 78 | away from the 500 range) 79 | 80 | Raises 81 | ------ 82 | ValueError 83 | If binding weights are invalid 84 | 85 | Returns 86 | ------- 87 | dict[str, tuple[DOMNode, Binding]] 88 | A dictionary of bindings 89 | """ 90 | existing_bindings = super().namespace_bindings 91 | if not self.BINDING_WEIGHTS: 92 | return existing_bindings 93 | builtin_index = 500 94 | max_weight = 999 95 | binding_range = range(builtin_index, builtin_index + len(existing_bindings)) 96 | weights = dict(zip(existing_bindings.keys(), binding_range)) 97 | if max(*self.BINDING_WEIGHTS.values(), 0) > max_weight: 98 | raise ValueError("Binding weights must be less than 1000") 99 | elif min(*self.BINDING_WEIGHTS.values(), 1) < 1: 100 | raise ValueError("Binding weights must be greater than 0") 101 | elif set(self.BINDING_WEIGHTS.values()).intersection(binding_range): 102 | raise ValueError("Binding weights must not overlap with existing bindings") 103 | weights.update(self.BINDING_WEIGHTS) 104 | updated_bindings = dict( 105 | sorted( 106 | existing_bindings.items(), 107 | key=lambda item: weights[item[0]], 108 | ), 109 | ) 110 | return updated_bindings 111 | -------------------------------------------------------------------------------- /browsr/browsr.css: -------------------------------------------------------------------------------- 1 | /* -- SCREENS -- */ 2 | 3 | CodeBrowserScreen { 4 | background: $surface-darken-1; 5 | } 6 | 7 | /* -- CodeBrowserScreen -- */ 8 | 9 | CodeBrowser { 10 | background: $surface-darken-1; 11 | color: $text; 12 | } 13 | 14 | CodeBrowser.-show-tree #tree-view { 15 | display: block; 16 | max-width: 50%; 17 | } 18 | 19 | #tree-view { 20 | display: none; 21 | scrollbar-gutter: stable; 22 | overflow: auto; 23 | width: auto; 24 | height: 100%; 25 | dock: left; 26 | } 27 | 28 | CurrentFileInfoBar { 29 | width: 100%; 30 | } 31 | 32 | #file-info-bar { 33 | dock: bottom; 34 | padding: 0 1; 35 | margin-bottom: 1; 36 | background: $accent-darken-3; 37 | color: $text; 38 | height: 1; 39 | } 40 | 41 | /* -- CodeBrowser -- */ 42 | 43 | /*WindowSwitcher {*/ 44 | 45 | /*}*/ 46 | 47 | /* -- WindowSwitcher -- */ 48 | 49 | VimScroll { 50 | overflow: auto scroll; 51 | min-width: 100%; 52 | } 53 | 54 | StaticWindow { 55 | width: auto; 56 | } 57 | 58 | DataTableWindow { 59 | overflow: auto scroll; 60 | min-width: 100%; 61 | height: 100%; 62 | } 63 | 64 | /* -- ConfirmationPopUp -- */ 65 | 66 | ConfirmationWindow { 67 | width: 100%; 68 | height: 100%; 69 | align: center middle; 70 | } 71 | 72 | ConfirmationPopUp { 73 | background: $boost; 74 | height: auto; 75 | max-width: 100; 76 | min-width: 40; 77 | border: wide $primary; 78 | padding: 1 2; 79 | margin: 1 2; 80 | box-sizing: border-box; 81 | } 82 | 83 | ConfirmationPopUp Button { 84 | margin-top: 1; 85 | width: 100%; 86 | } 87 | -------------------------------------------------------------------------------- /browsr/browsr.py: -------------------------------------------------------------------------------- 1 | """ 2 | Browsr TUI App 3 | 4 | This module contains the code browser app for the browsr package. 5 | This app was inspired by the CodeBrowser example from textual 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import os 11 | from os import getenv 12 | from typing import Any, ClassVar 13 | 14 | from textual import on 15 | from textual.binding import Binding, BindingType 16 | from textual.events import Mount 17 | 18 | from browsr.__about__ import __application__ 19 | from browsr.base import ( 20 | SortedBindingsApp, 21 | TextualAppContext, 22 | ) 23 | from browsr.screens import CodeBrowserScreen 24 | 25 | 26 | class Browsr(SortedBindingsApp): 27 | """ 28 | Textual code browser app. 29 | """ 30 | 31 | TITLE = __application__ 32 | CSS_PATH = "browsr.css" 33 | BINDINGS: ClassVar[list[BindingType]] = [ 34 | Binding(key="q", action="quit", description="Quit"), 35 | Binding(key="d", action="toggle_dark", description="Dark Mode"), 36 | ] 37 | BINDING_WEIGHTS: ClassVar[dict[str, int]] = { 38 | "ctrl+c": 1, 39 | "q": 2, 40 | "f": 3, 41 | "t": 4, 42 | "n": 5, 43 | "d": 6, 44 | "r": 995, 45 | ".": 996, 46 | "c": 997, 47 | "x": 998, 48 | } 49 | 50 | def __init__( 51 | self, 52 | config_object: TextualAppContext | None = None, 53 | *args: Any, 54 | **kwargs: Any, 55 | ) -> None: 56 | """ 57 | Like the textual.app.App class, but with an extra config_object property 58 | 59 | Parameters 60 | ---------- 61 | config_object: Optional[TextualAppContext] 62 | A configuration object. This is an optional python object, 63 | like a dictionary to pass into an application 64 | """ 65 | super().__init__(*args, **kwargs) 66 | self.config_object = config_object or TextualAppContext() 67 | self.code_browser_screen = CodeBrowserScreen(config_object=self.config_object) 68 | self.install_screen(self.code_browser_screen, name="code-browser") 69 | 70 | @on(Mount) 71 | async def mount_screen(self) -> None: 72 | """ 73 | Mount the screen 74 | """ 75 | await self.push_screen(screen=self.code_browser_screen) 76 | 77 | def action_copy_file_path(self) -> None: 78 | """ 79 | Copy the file path to the clipboard 80 | """ 81 | self.code_browser_screen.code_browser.copy_file_path() 82 | 83 | def action_download_file(self) -> None: 84 | """ 85 | Copy the file path to the clipboard 86 | """ 87 | self.code_browser_screen.code_browser.download_file_workflow() 88 | 89 | 90 | app = Browsr( 91 | config_object=TextualAppContext( 92 | file_path=getenv("BROWSR_PATH", os.getcwd()), debug=True 93 | ) 94 | ) 95 | 96 | if __name__ == "__main__": 97 | app.run() 98 | -------------------------------------------------------------------------------- /browsr/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | browsr command line interface 3 | """ 4 | 5 | import os 6 | from typing import Optional, Tuple 7 | 8 | import click 9 | import rich_click 10 | 11 | from browsr.__about__ import __application__, __version__ 12 | from browsr.base import ( 13 | TextualAppContext, 14 | ) 15 | from browsr.browsr import Browsr 16 | 17 | rich_click.rich_click.MAX_WIDTH = 100 18 | rich_click.rich_click.STYLE_OPTION = "bold green" 19 | rich_click.rich_click.STYLE_SWITCH = "bold blue" 20 | rich_click.rich_click.STYLE_METAVAR = "bold red" 21 | rich_click.rich_click.STYLE_HELPTEXT_FIRST_LINE = "bold blue" 22 | rich_click.rich_click.STYLE_HELPTEXT = "" 23 | rich_click.rich_click.STYLE_HEADER_TEXT = "bold green" 24 | rich_click.rich_click.STYLE_OPTION_DEFAULT = "bold yellow" 25 | rich_click.rich_click.STYLE_OPTION_HELP = "" 26 | rich_click.rich_click.STYLE_ERRORS_SUGGESTION = "bold red" 27 | rich_click.rich_click.STYLE_OPTIONS_TABLE_BOX = "SIMPLE_HEAVY" 28 | rich_click.rich_click.STYLE_COMMANDS_TABLE_BOX = "SIMPLE_HEAVY" 29 | 30 | 31 | @click.command(name="browsr", cls=rich_click.rich_command.RichCommand) 32 | @click.argument("path", default=None, required=False, metavar="PATH") 33 | @click.option( 34 | "-l", 35 | "--max-lines", 36 | default=1000, 37 | show_default=True, 38 | type=int, 39 | help="Maximum number of lines to display in the code browser", 40 | envvar="BROWSR_MAX_LINES", 41 | show_envvar=True, 42 | ) 43 | @click.option( 44 | "-m", 45 | "--max-file-size", 46 | default=20, 47 | show_default=True, 48 | type=int, 49 | help="Maximum file size in MB for the application to open", 50 | envvar="BROWSR_MAX_FILE_SIZE", 51 | show_envvar=True, 52 | ) 53 | @click.version_option(version=__version__, prog_name=__application__) 54 | @click.option( 55 | "--debug/--no-debug", 56 | default=False, 57 | help="Enable extra debugging output", 58 | type=click.BOOL, 59 | envvar="BROWSR_DEBUG", 60 | show_envvar=True, 61 | ) 62 | @click.option( 63 | "-k", 64 | "--kwargs", 65 | multiple=True, 66 | help="Key=Value pairs to pass to the filesystem", 67 | envvar="BROWSR_KWARGS", 68 | show_envvar=True, 69 | ) 70 | def browsr( 71 | path: Optional[str], 72 | debug: bool, 73 | max_lines: int, 74 | max_file_size: int, 75 | kwargs: Tuple[str, ...], 76 | ) -> None: 77 | """ 78 | browsr 🗂️ a pleasant file explorer in your terminal 79 | 80 | Navigate through directories and peek at files whether they're hosted locally, 81 | over SSH, in GitHub, AWS S3, Google Cloud Storage, or Azure Blob Storage. 82 | View code files with syntax highlighting, format JSON files, render images, 83 | convert data files to navigable datatables, and more. 84 | 85 | \f 86 | 87 | ![browsr](https://raw.githubusercontent.com/juftin/browsr/main/docs/_static/screenshot_utils.png) 88 | 89 | ## Installation 90 | 91 | It's recommended to install **`browsr`** via [pipx](https://pypa.github.io/pipx/) 92 | with **`all`** optional dependencies, this enables **`browsr`** to access 93 | remote cloud storage buckets and open parquet files. 94 | 95 | ```shell 96 | pipx install "browsr[all]" 97 | ``` 98 | 99 | ## Usage Examples 100 | 101 | ### Local 102 | 103 | #### Browse your current working directory 104 | 105 | ```shell 106 | browsr 107 | ``` 108 | 109 | #### Browse a local directory 110 | 111 | ```shell 112 | browsr/path/to/directory 113 | ``` 114 | 115 | ### Cloud Storage 116 | 117 | #### Browse an S3 bucket 118 | 119 | ```shell 120 | browsr s3://bucket-name 121 | ``` 122 | 123 | #### Browse a GCS bucket 124 | 125 | ```shell 126 | browsr gs://bucket-name 127 | ``` 128 | 129 | #### Browse Azure Services 130 | 131 | ```shell 132 | browsr adl://bucket-name 133 | browsr az://bucket-name 134 | ``` 135 | 136 | #### Pass Extra Arguments to Cloud Storage 137 | 138 | Some cloud storage providers require extra arguments to be passed to the 139 | filesystem. For example, to browse an anonymous S3 bucket, you need to pass 140 | the `anon=True` argument to the filesystem. This can be done with the `-k/--kwargs` 141 | argument. 142 | 143 | ```shell 144 | browsr s3://anonymous-bucket -k anon=True 145 | ``` 146 | 147 | ### GitHub 148 | 149 | #### Browse a GitHub repository 150 | 151 | ```shell 152 | browsr github://juftin:browsr 153 | ``` 154 | 155 | #### Browse a GitHub Repository Branch 156 | 157 | ```shell 158 | browsr github://juftin:browsr@main 159 | ``` 160 | 161 | #### Browse a Private GitHub Repository 162 | 163 | ```shell 164 | export GITHUB_TOKEN="ghp_1234567890" 165 | browsr github://juftin:browsr-private@main 166 | ``` 167 | 168 | #### Browse a GitHub Repository Subdirectory 169 | 170 | ```shell 171 | browsr github://juftin:browsr@main/tests 172 | ``` 173 | 174 | #### Browse a GitHub URL 175 | 176 | ```shell 177 | browsr https://github.com/juftin/browsr 178 | ``` 179 | 180 | #### Browse a Filesystem over SSH 181 | 182 | ``` 183 | browsr ssh://user@host:22 184 | ``` 185 | 186 | #### Browse a SFTP Server 187 | 188 | ``` 189 | browsr sftp://user@host:22/path/to/directory 190 | ``` 191 | 192 | ## Key Bindings 193 | - **`Q`** - Quit the application 194 | - **`F`** - Toggle the file tree sidebar 195 | - **`T`** - Toggle the rich theme for code formatting 196 | - **`N`** - Toggle line numbers for code formatting 197 | - **`D`** - Toggle dark mode for the application 198 | - **`.`** - Parent Directory - go up one directory 199 | - **`R`** - Reload the current directory 200 | - **`C`** - Copy the current file or directory path to the clipboard 201 | - **`X`** - Download the file from cloud storage 202 | """ 203 | extra_kwargs = {} 204 | if kwargs: 205 | for kwarg in kwargs: 206 | try: 207 | key, value = kwarg.split("=") 208 | extra_kwargs[key] = value 209 | except ValueError as ve: 210 | raise click.BadParameter( 211 | message=( 212 | f"Invalid Key/Value pair: `{kwarg}` " 213 | "- must be in the format Key=Value" 214 | ), 215 | param_hint="kwargs", 216 | ) from ve 217 | file_path = path or os.getcwd() 218 | config = TextualAppContext( 219 | file_path=file_path, 220 | debug=debug, 221 | max_file_size=max_file_size, 222 | max_lines=max_lines, 223 | kwargs=extra_kwargs, 224 | ) 225 | app = Browsr(config_object=config) 226 | app.run() 227 | 228 | 229 | if __name__ == "__main__": 230 | browsr() 231 | -------------------------------------------------------------------------------- /browsr/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | browsr configuration file 3 | """ 4 | 5 | from os import getenv 6 | from typing import List 7 | 8 | favorite_themes: List[str] = [ 9 | "monokai", 10 | "material", 11 | "dracula", 12 | "solarized-light", 13 | "one-dark", 14 | "solarized-dark", 15 | "emacs", 16 | "vim", 17 | "github-dark", 18 | "native", 19 | "paraiso-dark", 20 | ] 21 | rich_default_theme = getenv("RICH_THEME", None) 22 | 23 | if rich_default_theme in favorite_themes: 24 | favorite_themes.remove(rich_default_theme) 25 | if rich_default_theme is not None: 26 | favorite_themes.insert(0, rich_default_theme) 27 | 28 | image_file_extensions = [ 29 | ".bmp", 30 | ".dib", 31 | ".eps", 32 | ".ps", 33 | ".gif", 34 | ".icns", 35 | ".ico", 36 | ".cur", 37 | ".im", 38 | ".im.gz", 39 | ".im.bz2", 40 | ".jpg", 41 | ".jpe", 42 | ".jpeg", 43 | ".jfif", 44 | ".msp", 45 | ".pcx", 46 | ".png", 47 | ".ppm", 48 | ".pbm", 49 | ".pgm", 50 | ".sgi", 51 | ".rgb", 52 | ".bw", 53 | ".spi", 54 | ".tif", 55 | ".tiff", 56 | ".webp", 57 | ".xbm", 58 | ".xv", 59 | ".pdf", 60 | ] 61 | -------------------------------------------------------------------------------- /browsr/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | browsr.exceptions 3 | """ 4 | 5 | 6 | class FileSizeError(Exception): 7 | """ 8 | File Too Large Error 9 | """ 10 | -------------------------------------------------------------------------------- /browsr/screens/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Screens for browsr 3 | """ 4 | 5 | from browsr.screens.code_browser import CodeBrowserScreen 6 | 7 | __all__ = ["CodeBrowserScreen"] 8 | -------------------------------------------------------------------------------- /browsr/screens/code_browser.py: -------------------------------------------------------------------------------- 1 | """ 2 | The App Screen 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import ClassVar, Iterable, cast 8 | 9 | from rich import traceback 10 | from textual import on 11 | from textual.binding import Binding, BindingType 12 | from textual.containers import Horizontal 13 | from textual.events import Mount 14 | from textual.screen import Screen 15 | from textual.widget import Widget 16 | from textual.widgets import Footer, Header 17 | from textual_universal_directorytree import UPath 18 | 19 | from browsr.base import TextualAppContext 20 | from browsr.utils import get_file_info 21 | from browsr.widgets.code_browser import CodeBrowser 22 | from browsr.widgets.files import CurrentFileInfoBar 23 | 24 | 25 | class CodeBrowserScreen(Screen): 26 | """ 27 | Code Browser Screen 28 | """ 29 | 30 | BINDINGS: ClassVar[list[BindingType]] = [ 31 | Binding(key="f", action="toggle_files", description="Files"), 32 | Binding(key="t", action="theme", description="Theme"), 33 | Binding(key="n", action="linenos", description="Line Numbers"), 34 | Binding(key="r", action="reload", description="Reload"), 35 | Binding(key=".", action="parent_dir", description="Parent Directory"), 36 | ] 37 | 38 | def __init__( 39 | self, 40 | config_object: TextualAppContext | None = None, 41 | ) -> None: 42 | """ 43 | Like the textual.app.App class, but with an extra config_object property 44 | 45 | Parameters 46 | ---------- 47 | config_object: Optional[TextualAppContext] 48 | A configuration object. This is an optional python object, 49 | like a dictionary to pass into an application 50 | """ 51 | super().__init__() 52 | self.config_object = config_object or TextualAppContext() 53 | traceback.install(show_locals=True) 54 | self.header = Header() 55 | self.code_browser = CodeBrowser(config_object=self.config_object) 56 | self.file_information = CurrentFileInfoBar() 57 | self.info_bar = Horizontal( 58 | self.file_information, 59 | id="file-info-bar", 60 | ) 61 | if self.code_browser.selected_file_path is not None: 62 | self.file_information.file_info = get_file_info( 63 | file_path=self.code_browser.selected_file_path 64 | ) 65 | else: 66 | self.file_information.file_info = get_file_info(self.config_object.path) 67 | self.footer = Footer() 68 | 69 | def compose(self) -> Iterable[Widget]: 70 | """ 71 | Compose our UI. 72 | """ 73 | yield self.header 74 | yield self.code_browser 75 | yield self.info_bar 76 | yield self.footer 77 | 78 | @on(Mount) 79 | def start_up_app(self) -> None: 80 | """ 81 | On Application Mount - See If a File Should be Displayed 82 | """ 83 | if self.code_browser.selected_file_path is not None: 84 | self.code_browser.show_tree = self.code_browser.force_show_tree 85 | self.code_browser.window_switcher.render_file( 86 | file_path=self.code_browser.selected_file_path 87 | ) 88 | if ( 89 | self.code_browser.show_tree is False 90 | and self.code_browser.static_window.display is True 91 | ): 92 | self.code_browser.window_switcher.focus() 93 | elif ( 94 | self.code_browser.show_tree is False 95 | and self.code_browser.datatable_window.display is True 96 | ): 97 | self.code_browser.datatable_window.focus() 98 | else: 99 | self.code_browser.show_tree = True 100 | 101 | @on(CurrentFileInfoBar.FileInfoUpdate) 102 | def update_file_info(self, message: CurrentFileInfoBar.FileInfoUpdate) -> None: 103 | """ 104 | Update the file_info property 105 | """ 106 | self.file_information.file_info = message.new_file 107 | 108 | def action_toggle_files(self) -> None: 109 | """ 110 | Called in response to key binding. 111 | """ 112 | self.code_browser.show_tree = not self.code_browser.show_tree 113 | 114 | def action_parent_dir(self) -> None: 115 | """ 116 | Go to the parent directory 117 | """ 118 | directory_tree_open = self.code_browser.has_class("-show-tree") 119 | if not directory_tree_open: 120 | return 121 | if ( 122 | self.code_browser.directory_tree.path 123 | != self.code_browser.directory_tree.path.parent 124 | ): 125 | self.code_browser.directory_tree.path = ( 126 | self.code_browser.directory_tree.path.parent 127 | ) 128 | self.notify( 129 | title="Directory Changed", 130 | message=str(self.code_browser.directory_tree.path), 131 | severity="information", 132 | timeout=1, 133 | ) 134 | 135 | def action_theme(self) -> None: 136 | """ 137 | An action to toggle rich theme. 138 | """ 139 | self.code_browser.window_switcher.next_theme() 140 | 141 | def action_linenos(self) -> None: 142 | """ 143 | An action to toggle line numbers. 144 | """ 145 | if self.code_browser.selected_file_path is None: 146 | return 147 | self.code_browser.static_window.linenos = ( 148 | not self.code_browser.static_window.linenos 149 | ) 150 | 151 | def action_reload(self) -> None: 152 | """ 153 | Refresh the directory and file 154 | """ 155 | reload_file = self.code_browser.selected_file_path is not None 156 | reload_directory = self.code_browser.has_class("-show-tree") 157 | message_lines = [] 158 | if reload_directory: 159 | self.code_browser.directory_tree.reload() 160 | directory_name = self.code_browser.directory_tree.path.name or "/" 161 | message_lines.append( 162 | "[bold]Directory:[/bold] " f"[italic]{directory_name}[/italic]" 163 | ) 164 | if reload_file: 165 | selected_file_path = cast(UPath, self.code_browser.selected_file_path) 166 | file_name = selected_file_path.name 167 | self.code_browser.window_switcher.render_file( 168 | file_path=selected_file_path, 169 | scroll_home=False, 170 | ) 171 | message_lines.append("[bold]File:[/bold] " f"[italic]{file_name}[/italic]") 172 | if message_lines: 173 | self.notify( 174 | title="Reloaded", 175 | message="\n".join(message_lines), 176 | severity="information", 177 | timeout=1, 178 | ) 179 | -------------------------------------------------------------------------------- /browsr/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code Browsr Utility Functions 3 | """ 4 | 5 | import datetime 6 | import os 7 | from dataclasses import dataclass 8 | from typing import Any, BinaryIO, Dict, Optional, Union 9 | 10 | import fitz 11 | import rich_pixels 12 | from fitz import Pixmap 13 | from PIL import Image 14 | from rich_pixels import Pixels 15 | from textual_universal_directorytree import UPath, is_remote_path 16 | 17 | 18 | def _open_pdf_as_image(buf: BinaryIO) -> Image.Image: 19 | """ 20 | Open a PDF file and return a PIL.Image object 21 | """ 22 | doc = fitz.open(stream=buf.read(), filetype="pdf") 23 | pix: Pixmap = doc[0].get_pixmap() 24 | if pix.colorspace is None: 25 | mode = "L" 26 | elif pix.colorspace.n == 1: 27 | mode = "L" if pix.alpha == 0 else "LA" 28 | elif pix.colorspace.n == 3: # noqa: PLR2004 29 | mode = "RGB" if pix.alpha == 0 else "RGBA" 30 | else: 31 | mode = "CMYK" 32 | return Image.frombytes(size=(pix.width, pix.height), data=pix.samples, mode=mode) 33 | 34 | 35 | def open_image(document: UPath, screen_width: float) -> Pixels: 36 | """ 37 | Open an image file and return a rich_pixels.Pixels object 38 | """ 39 | with document.open("rb") as buf: 40 | if document.suffix.lower() == ".pdf": 41 | image = _open_pdf_as_image(buf=buf) 42 | else: 43 | image = Image.open(buf) 44 | image_width = image.width 45 | image_height = image.height 46 | size_ratio = image_width / screen_width 47 | new_width = min(int(image_width / size_ratio), image_width) 48 | new_height = min(int(image_height / size_ratio), image_height) 49 | resized = image.resize((new_width, new_height)) 50 | return rich_pixels.Pixels.from_image(resized) 51 | 52 | 53 | @dataclass 54 | class FileInfo: 55 | """ 56 | File Information Object 57 | """ 58 | 59 | file: UPath 60 | size: int 61 | last_modified: Optional[datetime.datetime] 62 | stat: Union[Dict[str, Any], os.stat_result] 63 | is_local: bool 64 | is_file: bool 65 | owner: str 66 | group: str 67 | is_cloudpath: bool 68 | 69 | 70 | def get_file_info(file_path: UPath) -> FileInfo: 71 | """ 72 | Get File Information, Regardless of the FileSystem 73 | """ 74 | try: 75 | stat: Union[Dict[str, Any], os.stat_result] = file_path.stat() 76 | is_file = file_path.is_file() 77 | except PermissionError: 78 | stat = {"size": 0} 79 | is_file = True 80 | except FileNotFoundError: 81 | stat = {"size": 0} 82 | is_file = True 83 | is_cloudpath = is_remote_path(file_path) 84 | if isinstance(stat, dict): 85 | lower_dict = {key.lower(): value for key, value in stat.items()} 86 | file_size = lower_dict["size"] 87 | modified_keys = ["lastmodified", "updated", "mtime"] 88 | last_modified = None 89 | for modified_key in modified_keys: 90 | if modified_key in lower_dict: 91 | last_modified = lower_dict[modified_key] 92 | break 93 | if isinstance(last_modified, str): 94 | last_modified = datetime.datetime.fromisoformat(last_modified[:-1]) 95 | return FileInfo( 96 | file=file_path, 97 | size=file_size, 98 | last_modified=last_modified, 99 | stat=stat, 100 | is_local=False, 101 | is_file=is_file, 102 | owner="", 103 | group="", 104 | is_cloudpath=is_cloudpath, 105 | ) 106 | else: 107 | last_modified = datetime.datetime.fromtimestamp( 108 | stat.st_mtime, tz=datetime.timezone.utc 109 | ) 110 | try: 111 | owner = file_path.owner() 112 | group = file_path.group() 113 | except NotImplementedError: 114 | owner = "" 115 | group = "" 116 | return FileInfo( 117 | file=file_path, 118 | size=stat.st_size, 119 | last_modified=last_modified, 120 | stat=stat, 121 | is_local=True, 122 | is_file=is_file, 123 | owner=owner, 124 | group=group, 125 | is_cloudpath=is_cloudpath, 126 | ) 127 | 128 | 129 | def handle_duplicate_filenames(file_path: UPath) -> UPath: 130 | """ 131 | Handle Duplicate Filenames 132 | 133 | Duplicate filenames are handled by appending a number to the filename 134 | in the form of "filename (1).ext", "filename (2).ext", etc. 135 | """ 136 | if not file_path.exists(): 137 | return file_path 138 | else: 139 | i = 1 140 | while True: 141 | new_file_stem = f"{file_path.stem} ({i})" 142 | new_file_path = file_path.with_stem(new_file_stem) 143 | if not new_file_path.exists(): 144 | return new_file_path 145 | i += 1 146 | 147 | 148 | def handle_github_url(url: str) -> str: 149 | """ 150 | Handle GitHub URLs 151 | 152 | GitHub URLs are handled by converting them to the raw URL. 153 | """ 154 | try: 155 | import requests 156 | except ImportError as e: 157 | raise ImportError( 158 | "The requests library is required to browse GitHub files. " 159 | "Install browsr with the `remote` extra to install requests." 160 | ) from e 161 | 162 | gitub_prefix = "github://" 163 | if gitub_prefix in url and "@" not in url: 164 | _, user_password = url.split("github://") 165 | org, repo_str = user_password.split(":") 166 | repo, *args = repo_str.split("/") 167 | elif gitub_prefix in url and "@" in url: 168 | return url 169 | elif "github.com" in url.lower(): 170 | _, org, repo, *args = url.split("/") 171 | else: 172 | msg = f"Invalid GitHub URL: {url}" 173 | raise ValueError(msg) 174 | token = os.getenv("GITHUB_TOKEN") 175 | auth = {"auth": ("Bearer", token)} if token is not None else {} 176 | resp = requests.get( 177 | f"https://api.github.com/repos/{org}/{repo}", 178 | headers={"Accept": "application/vnd.github.v3+json"}, 179 | timeout=10, 180 | **auth, # type: ignore[arg-type] 181 | ) 182 | resp.raise_for_status() 183 | default_branch = resp.json()["default_branch"] 184 | arg_str = "/".join(args) 185 | github_uri = f"{gitub_prefix}{org}:{repo}@{default_branch}/{arg_str}".rstrip("/") 186 | return github_uri 187 | 188 | 189 | class ArchiveFileError(Exception): 190 | """ 191 | Archive File Error 192 | """ 193 | -------------------------------------------------------------------------------- /browsr/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/browsr/widgets/__init__.py -------------------------------------------------------------------------------- /browsr/widgets/code_browser.py: -------------------------------------------------------------------------------- 1 | """ 2 | The Primary Content Container 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import inspect 8 | import pathlib 9 | import shutil 10 | from textwrap import dedent 11 | from typing import Any 12 | 13 | import pyperclip 14 | from rich.markdown import Markdown 15 | from textual import on, work 16 | from textual.app import ComposeResult 17 | from textual.containers import Container 18 | from textual.events import Mount 19 | from textual.reactive import var 20 | from textual.widgets import DirectoryTree 21 | from textual_universal_directorytree import ( 22 | UPath, 23 | is_remote_path, 24 | ) 25 | 26 | from browsr.base import ( 27 | TextualAppContext, 28 | ) 29 | from browsr.config import favorite_themes 30 | from browsr.exceptions import FileSizeError 31 | from browsr.utils import ( 32 | get_file_info, 33 | handle_duplicate_filenames, 34 | ) 35 | from browsr.widgets.confirmation import ConfirmationPopUp, ConfirmationWindow 36 | from browsr.widgets.double_click_directory_tree import DoubleClickDirectoryTree 37 | from browsr.widgets.files import CurrentFileInfoBar 38 | from browsr.widgets.universal_directory_tree import BrowsrDirectoryTree 39 | from browsr.widgets.windows import DataTableWindow, StaticWindow, WindowSwitcher 40 | 41 | 42 | class CodeBrowser(Container): 43 | """ 44 | The Code Browser 45 | 46 | This container contains the primary content of the application: 47 | 48 | - Universal Directory Tree 49 | - Space to view the selected file: 50 | - Code 51 | - Table 52 | - Image 53 | - Exceptions 54 | """ 55 | 56 | theme_index = var(0) 57 | rich_themes = favorite_themes 58 | show_tree = var(True) 59 | force_show_tree = var(False) 60 | selected_file_path: UPath | None | var[None] = var(None) 61 | 62 | hidden_table_view = var(False) 63 | table_view_status = var(False) 64 | static_window_status = var(False) 65 | 66 | def __init__( 67 | self, 68 | config_object: TextualAppContext, 69 | *args: Any, 70 | **kwargs: Any, 71 | ) -> None: 72 | """ 73 | Initialize the Browsr Renderer 74 | """ 75 | super().__init__(*args, **kwargs) 76 | self.config_object = config_object 77 | # Path Handling 78 | file_path = self.config_object.path 79 | if not file_path.exists(): 80 | msg = f"Unknown File Path: {file_path}" 81 | raise FileNotFoundError(msg) 82 | elif file_path.is_file(): 83 | self.selected_file_path = file_path 84 | file_path = file_path.parent 85 | elif file_path.is_dir() and file_path.joinpath("README.md").exists(): 86 | self.selected_file_path = file_path.joinpath("README.md") 87 | self.force_show_tree = True 88 | self.initial_file_path = file_path 89 | self.directory_tree = BrowsrDirectoryTree(file_path, id="tree-view") 90 | self.window_switcher = WindowSwitcher(config_object=self.config_object) 91 | self.confirmation = ConfirmationPopUp() 92 | self.confirmation_window = ConfirmationWindow( 93 | self.confirmation, id="confirmation-container" 94 | ) 95 | self.confirmation_window.display = False 96 | # Copy Pasting 97 | self._copy_function = pyperclip.determine_clipboard()[0] 98 | self._copy_supported = inspect.isfunction(self._copy_function) 99 | 100 | @property 101 | def datatable_window(self) -> DataTableWindow: 102 | """ 103 | Get the datatable window 104 | """ 105 | return self.window_switcher.datatable_window 106 | 107 | @property 108 | def static_window(self) -> StaticWindow: 109 | """ 110 | Get the static window 111 | """ 112 | return self.window_switcher.static_window 113 | 114 | def compose(self) -> ComposeResult: 115 | """ 116 | Compose the content of the container 117 | """ 118 | yield self.directory_tree 119 | yield self.window_switcher 120 | yield self.confirmation_window 121 | 122 | @on(Mount) 123 | def bind_keys(self) -> None: 124 | """ 125 | Bind Keys 126 | """ 127 | if self._copy_supported: 128 | self.app.bind( 129 | keys="c", action="copy_file_path", description="Copy Path", show=True 130 | ) 131 | if is_remote_path(self.initial_file_path): 132 | self.app.bind( 133 | keys="x", action="download_file", description="Download File", show=True 134 | ) 135 | 136 | def watch_show_tree(self, show_tree: bool) -> None: 137 | """ 138 | Called when show_tree is modified. 139 | """ 140 | self.set_class(show_tree, "-show-tree") 141 | 142 | def copy_file_path(self) -> None: 143 | """ 144 | Copy the file path to the clipboard. 145 | """ 146 | if self.selected_file_path and self._copy_supported: 147 | self._copy_function(str(self.selected_file_path)) 148 | self.notify( 149 | message=f"{self.selected_file_path}", 150 | title="Copied to Clipboard", 151 | severity="information", 152 | timeout=1, 153 | ) 154 | 155 | @on(ConfirmationPopUp.ConfirmationWindowDownload) 156 | def handle_download_confirmation( 157 | self, _: ConfirmationPopUp.ConfirmationWindowDownload 158 | ) -> None: 159 | """ 160 | Handle the download confirmation. 161 | """ 162 | self.download_selected_file() 163 | 164 | @on(ConfirmationPopUp.DisplayToggle) 165 | def handle_table_view_display_toggle( 166 | self, _: ConfirmationPopUp.DisplayToggle 167 | ) -> None: 168 | """ 169 | Handle the table view display toggle. 170 | """ 171 | self.datatable_window.display = self.table_view_status 172 | self.window_switcher.vim_scroll.display = self.static_window_status 173 | 174 | @on(DirectoryTree.FileSelected) 175 | def handle_file_selected(self, message: DirectoryTree.FileSelected) -> None: 176 | """ 177 | Called when the user click a file in the directory tree. 178 | """ 179 | self.selected_file_path = message.path 180 | file_info = get_file_info(file_path=self.selected_file_path) 181 | try: 182 | self.static_window.handle_file_size( 183 | file_info=file_info, max_file_size=self.config_object.max_file_size 184 | ) 185 | self.window_switcher.render_file(file_path=self.selected_file_path) 186 | except FileSizeError as e: 187 | error_message = self.static_window.handle_exception(exception=e) 188 | error_syntax = self.static_window.text_to_syntax( 189 | text=error_message, file_path=self.selected_file_path 190 | ) 191 | self.static_window.update(error_syntax) 192 | self.window_switcher.switch_window(self.static_window) 193 | self.post_message(CurrentFileInfoBar.FileInfoUpdate(new_file=file_info)) 194 | 195 | @on(DoubleClickDirectoryTree.DirectoryDoubleClicked) 196 | def handle_directory_double_click( 197 | self, message: DoubleClickDirectoryTree.DirectoryDoubleClicked 198 | ) -> None: 199 | """ 200 | Called when the user double clicks a directory in the directory tree. 201 | """ 202 | self.directory_tree.path = message.path 203 | self.notify( 204 | title="Directory Changed", 205 | message=str(message.path), 206 | severity="information", 207 | timeout=1, 208 | ) 209 | 210 | @on(DoubleClickDirectoryTree.FileDoubleClicked) 211 | def handle_file_double_click( 212 | self, message: DoubleClickDirectoryTree.FileDoubleClicked 213 | ) -> None: 214 | """ 215 | Called when the user double clicks a file in the directory tree. 216 | """ 217 | if self._copy_supported: 218 | self._copy_function(str(message.path)) 219 | self.notify( 220 | message=f"{message.path}", 221 | title="Copied to Clipboard", 222 | severity="information", 223 | timeout=1, 224 | ) 225 | 226 | def download_file_workflow(self) -> None: 227 | """ 228 | Download the selected file. 229 | """ 230 | if self.selected_file_path is None: 231 | return 232 | elif self.selected_file_path.is_dir(): 233 | return 234 | elif is_remote_path(self.selected_file_path): 235 | handled_download_path = self._get_download_file_name() 236 | prompt_message: str = dedent( 237 | f""" 238 | ## File Download 239 | 240 | **Are you sure you want to download that file?** 241 | 242 | **File:** `{self.selected_file_path}` 243 | 244 | **Path:** `{handled_download_path}` 245 | """ 246 | ) 247 | self.confirmation.download_message.update(Markdown(prompt_message)) 248 | self.confirmation.refresh() 249 | self.table_view_status = self.datatable_window.display 250 | self.static_window_status = self.window_switcher.vim_scroll.display 251 | self.datatable_window.display = False 252 | self.window_switcher.vim_scroll.display = False 253 | self.confirmation_window.display = True 254 | 255 | @work(thread=True) 256 | def download_selected_file(self) -> None: 257 | """ 258 | Download the selected file. 259 | """ 260 | if self.selected_file_path is None: 261 | return 262 | elif self.selected_file_path.is_dir(): 263 | return 264 | elif is_remote_path(self.selected_file_path): 265 | handled_download_path = self._get_download_file_name() 266 | with self.selected_file_path.open("rb") as file_handle: 267 | with handled_download_path.open("wb") as download_handle: 268 | shutil.copyfileobj(file_handle, download_handle) 269 | self.notify( 270 | message=str(handled_download_path), 271 | title="Download Complete", 272 | severity="information", 273 | timeout=2, 274 | ) 275 | 276 | def _get_download_file_name(self) -> UPath: 277 | """ 278 | Get the download file name. 279 | """ 280 | download_dir = pathlib.Path.home() / "Downloads" 281 | if not download_dir.exists(): 282 | msg = f"Download directory {download_dir} not found" 283 | raise FileNotFoundError(msg) 284 | download_path = download_dir / self.selected_file_path.name # type: ignore[union-attr] 285 | handled_download_path = handle_duplicate_filenames(file_path=download_path) 286 | return handled_download_path 287 | -------------------------------------------------------------------------------- /browsr/widgets/confirmation.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from rich.markdown import Markdown 4 | from textual import on 5 | from textual.app import ComposeResult 6 | from textual.containers import Container 7 | from textual.message import Message 8 | from textual.widgets import Button, Static 9 | 10 | 11 | class ConfirmationPopUp(Container): 12 | """ 13 | A Pop Up that asks for confirmation 14 | """ 15 | 16 | __confirmation_message__: str = dedent( 17 | """ 18 | ## File Download 19 | 20 | Are you sure you want to download that file? 21 | """ 22 | ) 23 | 24 | class ConfirmationWindowDownload(Message): 25 | """ 26 | Confirmation Window 27 | """ 28 | 29 | class ConfirmationWindowDisplay(Message): 30 | """ 31 | Confirmation Window 32 | """ 33 | 34 | def __init__(self, display: bool) -> None: 35 | self.display = display 36 | super().__init__() 37 | 38 | class DisplayToggle(Message): 39 | """ 40 | TableView Display 41 | """ 42 | 43 | def compose(self) -> ComposeResult: 44 | """ 45 | Compose the Confirmation Pop Up 46 | """ 47 | self.download_message = Static(Markdown("")) 48 | yield self.download_message 49 | yield Button("Yes", variant="success") 50 | yield Button("No", variant="error") 51 | 52 | @on(Button.Pressed) 53 | def handle_download_selection(self, message: Button.Pressed) -> None: 54 | """ 55 | Handle Button Presses 56 | """ 57 | self.post_message(self.ConfirmationWindowDisplay(display=False)) 58 | if message.button.variant == "success": 59 | self.post_message(self.ConfirmationWindowDownload()) 60 | self.post_message(self.DisplayToggle()) 61 | 62 | 63 | class ConfirmationWindow(Container): 64 | """ 65 | Window containing the Confirmation Pop Up 66 | """ 67 | 68 | @on(ConfirmationPopUp.ConfirmationWindowDisplay) 69 | def handle_confirmation_window_display( 70 | self, message: ConfirmationPopUp.ConfirmationWindowDisplay 71 | ) -> None: 72 | """ 73 | Handle Confirmation Window Display 74 | """ 75 | self.display = message.display 76 | -------------------------------------------------------------------------------- /browsr/widgets/double_click_directory_tree.py: -------------------------------------------------------------------------------- 1 | """ 2 | Directory Tree that copeis the path to the clipboard on double click 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import datetime 8 | from typing import Any, ClassVar 9 | 10 | from textual import on 11 | from textual.message import Message 12 | from textual.widgets import DirectoryTree 13 | from textual_universal_directorytree import UPath 14 | 15 | 16 | class DoubleClickDirectoryTree(DirectoryTree): 17 | """ 18 | A DirectoryTree that can handle any filesystem. 19 | """ 20 | 21 | _double_click_time: ClassVar[datetime.timedelta] = datetime.timedelta( 22 | seconds=0.333333 23 | ) 24 | 25 | def __init__(self, *args: Any, **kwargs: Any) -> None: 26 | """ 27 | Initialize the DirectoryTree 28 | """ 29 | super().__init__(*args, **kwargs) 30 | self._last_clicked_path: UPath = UPath( 31 | "13041530b3174c569e1895fdfc2676fc57af1e02606059e0d2472d04c1bb360f" 32 | ) 33 | self._last_clicked_time = datetime.datetime( 34 | 1970, 1, 1, tzinfo=datetime.timezone.utc 35 | ) 36 | 37 | class DoubleClicked(Message): 38 | """ 39 | A message that is emitted when the directory is changed 40 | """ 41 | 42 | def __init__(self, path: UPath) -> None: 43 | """ 44 | Initialize the message 45 | """ 46 | self.path = path 47 | super().__init__() 48 | 49 | class DirectoryDoubleClicked(DoubleClicked): 50 | """ 51 | A message that is emitted when the directory is double clicked 52 | """ 53 | 54 | class FileDoubleClicked(DoubleClicked): 55 | """ 56 | A message that is emitted when the file is double clicked 57 | """ 58 | 59 | @on(DirectoryTree.DirectorySelected) 60 | def handle_double_click_dir(self, message: DirectoryTree.DirectorySelected) -> None: 61 | """ 62 | Handle double clicking on a directory 63 | """ 64 | if ( 65 | self.is_double_click(path=message.path) 66 | and message.path != self.root.data.path 67 | ): 68 | message.stop() 69 | self.post_message(self.DirectoryDoubleClicked(path=message.path)) 70 | 71 | @on(DirectoryTree.FileSelected) 72 | def handle_double_click_file(self, message: DirectoryTree.FileSelected) -> None: 73 | """ 74 | Handle double clicking on a file 75 | """ 76 | if self.is_double_click(path=message.path): 77 | message.stop() 78 | self.post_message(self.FileDoubleClicked(path=message.path)) 79 | 80 | def is_double_click(self, path: UPath) -> bool: 81 | """ 82 | Check if the path is double clicked 83 | """ 84 | click_time = datetime.datetime.now(datetime.timezone.utc) 85 | click_delta = click_time - self._last_clicked_time 86 | self._last_clicked_time = click_time 87 | if self._last_clicked_path == path and click_delta <= self._double_click_time: 88 | return True 89 | elif self._last_clicked_path == path and click_delta > self._double_click_time: 90 | return False 91 | else: 92 | self._last_clicked_path = path 93 | return False 94 | -------------------------------------------------------------------------------- /browsr/widgets/files.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | 5 | from rich.console import RenderableType 6 | from rich.text import Text 7 | from textual.message import Message 8 | from textual.reactive import reactive 9 | from textual.widget import Widget 10 | from textual_universal_directorytree import ( 11 | GitHubTextualPath, 12 | S3TextualPath, 13 | SFTPTextualPath, 14 | is_remote_path, 15 | ) 16 | 17 | from browsr.utils import FileInfo 18 | 19 | 20 | class CurrentFileInfoBar(Widget): 21 | """ 22 | A Widget that displays information about the currently selected file 23 | 24 | Thanks, Kupo. https://github.com/darrenburns/kupo 25 | """ 26 | 27 | file_info: FileInfo | None = reactive(None) 28 | 29 | class FileInfoUpdate(Message): 30 | """ 31 | File Info Bar Update 32 | """ 33 | 34 | def __init__(self, new_file: FileInfo | None) -> None: 35 | self.new_file = new_file 36 | super().__init__() 37 | 38 | def watch_file_info(self, new_file: FileInfo | None) -> None: 39 | """ 40 | Watch the file_info property for changes 41 | """ 42 | if new_file is None: 43 | self.display = False 44 | else: 45 | self.display = True 46 | 47 | @classmethod 48 | def _convert_size(cls, size_bytes: int) -> str: 49 | """ 50 | Convert Bytes to Human Readable String 51 | """ 52 | if size_bytes == 0: 53 | return " 0B" 54 | size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") 55 | index = int(math.floor(math.log(size_bytes, 1024))) 56 | p = math.pow(1024, index) 57 | number = round(size_bytes / p, 2) 58 | unit = size_name[index] 59 | return f"{number:.0f}{unit}" 60 | 61 | def render(self) -> RenderableType: 62 | """ 63 | Render the Current File Info Bar 64 | """ 65 | status_string = self.render_file_protocol() 66 | if self.file_info is None: 67 | return Text(status_string) 68 | elif self.file_info.is_file: 69 | file_options = self.render_file_options() 70 | status_string += file_options 71 | if ( 72 | self.file_info.last_modified is not None 73 | and self.file_info.last_modified.timestamp() != 0 74 | ): 75 | modify_time = self.file_info.last_modified.strftime("%b %d, %Y %I:%M %p") 76 | status_string += f" 📅 {modify_time}" 77 | directory_options = self.render_directory_options() 78 | status_string += directory_options 79 | return Text(status_string.strip(), style="dim") 80 | 81 | def render_file_options(self) -> str: 82 | """ 83 | Render the file options 84 | """ 85 | status_string = "" 86 | if not self.file_info: 87 | return status_string 88 | if self.file_info.is_file: 89 | file_size = self._convert_size(self.file_info.size) 90 | status_string += f" 🗄️️ {file_size}" 91 | if self.file_info.owner not in ["", None]: 92 | status_string += f" 👤 {self.file_info.owner}" 93 | if self.file_info.group.strip() not in ["", None]: 94 | status_string += f" 🏠 {self.file_info.group}" 95 | return status_string 96 | 97 | def render_directory_options(self) -> str: 98 | """ 99 | Render the directory options 100 | """ 101 | status_string = "" 102 | if not self.file_info: 103 | return status_string 104 | if self.file_info.is_file: 105 | directory_name = self.file_info.file.parent.name 106 | if not directory_name or ( 107 | self.file_info.file.protocol 108 | and f"{self.file_info.file.protocol}://" in directory_name 109 | ): 110 | directory_name = str(self.file_info.file.parent) 111 | directory_name = directory_name.lstrip( 112 | f"{self.file_info.file.protocol}://" 113 | ) 114 | directory_name = directory_name.rstrip("/") 115 | status_string += f" 📂 {directory_name}" 116 | status_string += f" 💾 {self.file_info.file.name}" 117 | else: 118 | status_string += f" 📂 {self.file_info.file.name}" 119 | return status_string 120 | 121 | def render_file_protocol(self) -> str: 122 | """ 123 | Render the file protocol 124 | """ 125 | status_string = "" 126 | if not self.file_info: 127 | return status_string 128 | if is_remote_path(self.file_info.file): 129 | if isinstance(self.file_info.file, GitHubTextualPath): 130 | protocol = "GitHub" 131 | elif isinstance(self.file_info.file, S3TextualPath): 132 | protocol = "S3" 133 | elif isinstance(self.file_info.file, SFTPTextualPath): 134 | protocol = "SFTP" 135 | else: 136 | protocol = self.file_info.file.protocol 137 | status_string += f"🗂️ {protocol}" 138 | return status_string 139 | -------------------------------------------------------------------------------- /browsr/widgets/universal_directory_tree.py: -------------------------------------------------------------------------------- 1 | """ 2 | A universal directory tree widget for Textual. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import ClassVar, Iterable 8 | 9 | from textual.binding import BindingType 10 | from textual.widgets._directory_tree import DirEntry 11 | from textual.widgets._tree import TreeNode 12 | from textual_universal_directorytree import UniversalDirectoryTree, UPath 13 | 14 | from browsr.widgets.double_click_directory_tree import DoubleClickDirectoryTree 15 | from browsr.widgets.vim import vim_cursor_bindings 16 | 17 | 18 | class BrowsrDirectoryTree(DoubleClickDirectoryTree, UniversalDirectoryTree): 19 | """ 20 | A DirectoryTree that can handle any filesystem. 21 | """ 22 | 23 | BINDINGS: ClassVar[list[BindingType]] = [ 24 | *UniversalDirectoryTree.BINDINGS, 25 | *vim_cursor_bindings, 26 | ] 27 | 28 | @classmethod 29 | def _handle_top_level_bucket(cls, dir_path: UPath) -> Iterable[UPath] | None: 30 | """ 31 | Handle scenarios when someone wants to browse all of s3 32 | 33 | This is because S3FS handles the root directory differently 34 | than other filesystems 35 | """ 36 | if str(dir_path) == "s3:/": 37 | sub_buckets = sorted( 38 | UPath(f"s3://{bucket.name}") for bucket in dir_path.iterdir() 39 | ) 40 | return sub_buckets 41 | return None 42 | 43 | def _populate_node( 44 | self, node: TreeNode[DirEntry], content: Iterable[UPath] 45 | ) -> None: 46 | """ 47 | Populate the given tree node with the given directory content. 48 | 49 | This function overrides the original textual method to handle root level 50 | cloud buckets. 51 | """ 52 | top_level_buckets = self._handle_top_level_bucket(dir_path=node.data.path) 53 | if top_level_buckets is not None: 54 | content = top_level_buckets 55 | node.remove_children() 56 | for path in content: 57 | if top_level_buckets is not None: 58 | path_name = str(path).replace("s3://", "").rstrip("/") 59 | else: 60 | path_name = path.name 61 | node.add( 62 | path_name, 63 | data=DirEntry(path), 64 | allow_expand=self._safe_is_dir(path), 65 | ) 66 | node.expand() 67 | -------------------------------------------------------------------------------- /browsr/widgets/vim.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar 4 | 5 | from textual.binding import Binding, BindingType 6 | from textual.containers import VerticalScroll 7 | from textual.widgets import DataTable 8 | 9 | vim_scroll_bindings = [ 10 | Binding(key="k", action="scroll_up", description="Scroll Up", show=False), 11 | Binding(key="j", action="scroll_down", description="Scroll Down", show=False), 12 | Binding(key="h", action="scroll_left", description="Scroll Left", show=False), 13 | Binding(key="l", action="scroll_right", description="Scroll Right", show=False), 14 | ] 15 | vim_cursor_bindings = [ 16 | Binding(key="k", action="cursor_up", description="Cursor Up", show=False), 17 | Binding(key="j", action="cursor_down", description="Cursor Down", show=False), 18 | Binding(key="h", action="cursor_left", description="Cursor Left", show=False), 19 | Binding(key="l", action="cursor_right", description="Cursor Right", show=False), 20 | ] 21 | 22 | 23 | class VimScroll(VerticalScroll): 24 | """ 25 | A VerticalScroll with Vim Keybindings 26 | """ 27 | 28 | BINDINGS: ClassVar[list[BindingType]] = [ 29 | *VerticalScroll.BINDINGS, 30 | *vim_scroll_bindings, 31 | ] 32 | 33 | 34 | class VimDataTable(DataTable[str]): 35 | """ 36 | A DataTable with Vim Keybindings 37 | """ 38 | 39 | BINDINGS: ClassVar[list[BindingType]] = [ 40 | *DataTable.BINDINGS, 41 | *vim_cursor_bindings, 42 | ] 43 | -------------------------------------------------------------------------------- /browsr/widgets/windows.py: -------------------------------------------------------------------------------- 1 | """ 2 | Content Windows 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import json 8 | from json import JSONDecodeError 9 | from typing import Any, ClassVar 10 | 11 | import numpy as np 12 | import pandas as pd 13 | from art import text2art 14 | from rich.markdown import Markdown 15 | from rich.syntax import Syntax 16 | from rich_pixels import Pixels 17 | from textual.app import ComposeResult 18 | from textual.containers import Container 19 | from textual.message import Message 20 | from textual.reactive import Reactive, reactive 21 | from textual.widget import Widget 22 | from textual.widgets import Static 23 | from textual_universal_directorytree import UPath 24 | 25 | from browsr.base import TextualAppContext 26 | from browsr.config import favorite_themes, image_file_extensions 27 | from browsr.exceptions import FileSizeError 28 | from browsr.utils import ( 29 | ArchiveFileError, 30 | FileInfo, 31 | open_image, 32 | ) 33 | from browsr.widgets.vim import VimDataTable, VimScroll 34 | 35 | 36 | class BaseCodeWindow(Widget): 37 | """ 38 | Base code view widget 39 | """ 40 | 41 | archive_extensions: ClassVar[list[str]] = [".tar", ".gz", ".zip", ".tgz"] 42 | 43 | class WindowSwitch(Message): 44 | """ 45 | Switch to the window 46 | """ 47 | 48 | def __init__(self, window: type[BaseCodeWindow], scroll_home: bool = False): 49 | self.window: type[BaseCodeWindow] = window 50 | self.scroll_home: bool = scroll_home 51 | super().__init__() 52 | 53 | def file_to_string(self, file_path: UPath, max_lines: int | None = None) -> str: 54 | """ 55 | Load a file into a string 56 | """ 57 | try: 58 | if file_path.suffix in self.archive_extensions: 59 | message = f"Cannot render archive file {file_path}." 60 | raise ArchiveFileError(message) 61 | text = file_path.read_text(encoding="utf-8") 62 | except Exception as e: 63 | text = self.handle_exception(exception=e) 64 | if max_lines: 65 | text = "\n".join(text.split("\n")[:max_lines]) 66 | return text 67 | 68 | def file_to_image(self, file_path: UPath) -> Pixels: 69 | """ 70 | Load a file into an image 71 | """ 72 | screen_width = self.app.size.width / 4 73 | content = open_image(document=file_path, screen_width=screen_width) 74 | return content 75 | 76 | def file_to_json(self, file_path: UPath, max_lines: int | None = None) -> str: 77 | """ 78 | Load a file into a JSON object 79 | """ 80 | code_str = self.file_to_string(file_path=file_path) 81 | try: 82 | code_obj = json.loads(code_str) 83 | code_str = json.dumps(code_obj, indent=2) 84 | except JSONDecodeError: 85 | pass 86 | if max_lines: 87 | code_str = "\n".join(code_str.split("\n")[:max_lines]) 88 | return code_str 89 | 90 | @classmethod 91 | def handle_file_size(cls, file_info: FileInfo, max_file_size: int = 5) -> None: 92 | """ 93 | Handle a File Size 94 | """ 95 | file_size_mb = file_info.size / 1000 / 1000 96 | too_large = file_size_mb >= max_file_size 97 | if too_large: 98 | raise FileSizeError("File too large") 99 | 100 | @classmethod 101 | def handle_exception(cls, exception: Exception) -> str: 102 | """ 103 | Handle an exception 104 | 105 | This method is used to handle exceptions that occur when rendering a file. 106 | When an uncommon exception occurs, the method will raise the exception. 107 | 108 | Parameters 109 | ---------- 110 | exception: Exception 111 | The exception that occurred. 112 | 113 | Raises 114 | ------ 115 | Exception 116 | If the exception is not one of the expected exceptions. 117 | 118 | Returns 119 | ------- 120 | str 121 | The error message to display. 122 | """ 123 | font = "univers" 124 | if isinstance(exception, ArchiveFileError): 125 | error_message = ( 126 | text2art("ARCHIVE", font=font) + "\n\n" + text2art("FILE", font=font) 127 | ) 128 | elif isinstance(exception, FileSizeError): 129 | error_message = ( 130 | text2art("FILE TOO", font=font) + "\n\n" + text2art("LARGE", font=font) 131 | ) 132 | elif isinstance(exception, PermissionError): 133 | error_message = ( 134 | text2art("PERMISSION", font=font) 135 | + "\n\n" 136 | + text2art("ERROR", font=font) 137 | ) 138 | elif isinstance(exception, UnicodeError): 139 | error_message = ( 140 | text2art("ENCODING", font=font) + "\n\n" + text2art("ERROR", font=font) 141 | ) 142 | elif isinstance(exception, FileNotFoundError): 143 | error_message = ( 144 | text2art("FILE NOT", font=font) + "\n\n" + text2art("FOUND", font=font) 145 | ) 146 | else: 147 | raise exception from exception 148 | return error_message 149 | 150 | 151 | class StaticWindow(Static, BaseCodeWindow): 152 | """ 153 | A static widget for displaying code. 154 | """ 155 | 156 | linenos: Reactive[bool] = reactive(False) 157 | theme: Reactive[str] = reactive(favorite_themes[0]) 158 | 159 | rich_themes: ClassVar[list[str]] = favorite_themes 160 | 161 | def __init__( 162 | self, config_object: TextualAppContext, *args: Any, **kwargs: Any 163 | ) -> None: 164 | super().__init__(*args, **kwargs) 165 | self.config_object = config_object 166 | 167 | def file_to_markdown( 168 | self, file_path: UPath, max_lines: int | None = None 169 | ) -> Markdown: 170 | """ 171 | Load a file into a Markdown 172 | """ 173 | return Markdown( 174 | self.file_to_string(file_path, max_lines=max_lines), 175 | code_theme=self.theme, 176 | hyperlinks=True, 177 | ) 178 | 179 | def text_to_syntax(self, text: str, file_path: str | UPath) -> Syntax: 180 | """ 181 | Convert text to syntax 182 | """ 183 | lexer = Syntax.guess_lexer(str(file_path), code=text) 184 | return Syntax( 185 | code=text, 186 | lexer=lexer, 187 | line_numbers=self.linenos, 188 | word_wrap=False, 189 | indent_guides=False, 190 | theme=self.theme, 191 | ) 192 | 193 | def watch_linenos(self, linenos: bool) -> None: 194 | """ 195 | Called when linenos is modified. 196 | """ 197 | if isinstance(self.renderable, Syntax): 198 | self.renderable.line_numbers = linenos 199 | 200 | def watch_theme(self, theme: str) -> None: 201 | """ 202 | Called when theme is modified. 203 | """ 204 | if isinstance(self.renderable, Syntax): 205 | updated_syntax = Syntax( 206 | code=self.renderable.code, 207 | lexer=self.renderable.lexer, 208 | line_numbers=self.renderable.line_numbers, 209 | word_wrap=False, 210 | indent_guides=False, 211 | theme=theme, 212 | ) 213 | self.update(updated_syntax) 214 | elif isinstance(self.renderable, Markdown): 215 | self.renderable.code_theme = self.theme 216 | 217 | def next_theme(self) -> str | None: 218 | """ 219 | Switch to the next theme 220 | """ 221 | if not isinstance(self.renderable, (Syntax, Markdown)): 222 | return None 223 | current_index = favorite_themes.index(self.theme) 224 | next_theme = favorite_themes[(current_index + 1) % len(favorite_themes)] 225 | self.theme = next_theme 226 | return next_theme 227 | 228 | 229 | class DataTableWindow(VimDataTable, BaseCodeWindow): 230 | """ 231 | A DataTable widget for displaying code. 232 | """ 233 | 234 | def refresh_from_file(self, file_path: UPath, max_lines: int | None = None) -> None: 235 | """ 236 | Load a file into a DataTable 237 | """ 238 | if ".csv" in file_path.suffixes: 239 | df = pd.read_csv(file_path, nrows=max_lines) 240 | elif file_path.suffix.lower() in [".parquet"]: 241 | df = pd.read_parquet(file_path).head(max_lines) 242 | elif file_path.suffix.lower() in [".feather", ".fea"]: 243 | df = pd.read_feather(file_path).head(max_lines) 244 | else: 245 | msg = f"Cannot render file as a DataTable, {file_path}." 246 | raise NotImplementedError(msg) 247 | self.refresh_from_df(df) 248 | 249 | def refresh_from_df( 250 | self, 251 | pandas_dataframe: pd.DataFrame, 252 | show_index: bool = True, 253 | index_name: str | None = None, 254 | ) -> None: 255 | """ 256 | Convert a pandas.DataFrame obj into a rich.Table obj. 257 | 258 | Parameters 259 | ---------- 260 | pandas_dataframe: pd.DataFrame 261 | A Pandas DataFrame to be converted to a rich Table. 262 | show_index: bool 263 | Add a column with a row count to the table. Defaults to True. 264 | index_name: Optional[str] 265 | The column name to give to the index column. 266 | Defaults to None, showing no value. 267 | 268 | Returns 269 | ------- 270 | DataTableWindow[str] 271 | The DataTable instance passed, populated with the DataFrame values. 272 | """ 273 | self.clear(columns=True) 274 | if show_index: 275 | index_name = str(index_name) if index_name else "" 276 | self.add_column(index_name) 277 | for column in pandas_dataframe.columns: 278 | self.add_column(str(column)) 279 | pandas_dataframe.replace([np.NaN], [""], inplace=True) 280 | for index, value_list in enumerate(pandas_dataframe.values.tolist()): 281 | row = [str(index)] if show_index else [] 282 | row += [str(x) for x in value_list] 283 | self.add_row(*row) 284 | 285 | 286 | class WindowSwitcher(Container): 287 | """ 288 | A container that contains the file content windows 289 | """ 290 | 291 | show_tree: Reactive[bool] = reactive(True) 292 | 293 | datatable_extensions: ClassVar[list[str]] = [ 294 | ".csv", 295 | ".parquet", 296 | ".feather", 297 | ".fea", 298 | ".csv.gz", 299 | ] 300 | image_extensions: ClassVar[list[str]] = image_file_extensions.copy() 301 | markdown_extensions: ClassVar[list[str]] = [".md"] 302 | json_extensions: ClassVar[list[str]] = [".json"] 303 | 304 | def __init__( 305 | self, config_object: TextualAppContext, *args: Any, **kwargs: Any 306 | ) -> None: 307 | super().__init__(*args, **kwargs) 308 | self.config_object = config_object 309 | self.static_window = StaticWindow(expand=True, config_object=config_object) 310 | self.datatable_window = DataTableWindow( 311 | zebra_stripes=True, show_header=True, show_cursor=True, id="table-view" 312 | ) 313 | self.datatable_window.display = False 314 | self.vim_scroll = VimScroll(self.static_window) 315 | self.rendered_file: UPath | None = None 316 | 317 | def compose(self) -> ComposeResult: 318 | """ 319 | Compose the widget 320 | """ 321 | yield self.vim_scroll 322 | yield self.datatable_window 323 | 324 | def get_active_widget(self) -> Widget: 325 | """ 326 | Get the active widget 327 | """ 328 | if self.vim_scroll.display: 329 | return self.vim_scroll 330 | elif self.datatable_window.display: 331 | return self.datatable_window 332 | 333 | def switch_window(self, window: BaseCodeWindow) -> None: 334 | """ 335 | Switch to the window 336 | """ 337 | screens: dict[Widget, Widget] = { 338 | self.static_window: self.vim_scroll, 339 | self.datatable_window: self.datatable_window, 340 | } 341 | for window_screen in screens: 342 | if window is window_screen: 343 | screens[window_screen].display = True 344 | else: 345 | screens[window_screen].display = False 346 | 347 | def render_file(self, file_path: UPath, scroll_home: bool = True) -> None: 348 | """ 349 | Render a file 350 | """ 351 | switch_window = self.static_window 352 | joined_suffixes = "".join(file_path.suffixes).lower() 353 | if joined_suffixes in self.datatable_extensions: 354 | self.datatable_window.refresh_from_file( 355 | file_path=file_path, max_lines=self.config_object.max_lines 356 | ) 357 | switch_window = self.datatable_window 358 | elif file_path.suffix.lower() in self.image_extensions: 359 | image = self.static_window.file_to_image(file_path=file_path) 360 | self.static_window.update(image) 361 | elif file_path.suffix.lower() in self.markdown_extensions: 362 | markdown = self.static_window.file_to_markdown( 363 | file_path=file_path, max_lines=self.config_object.max_lines 364 | ) 365 | self.static_window.update(markdown) 366 | elif file_path.suffix.lower() in self.json_extensions: 367 | json_str = self.static_window.file_to_json( 368 | file_path=file_path, max_lines=self.config_object.max_lines 369 | ) 370 | json_syntax = self.static_window.text_to_syntax( 371 | text=json_str, file_path=file_path 372 | ) 373 | self.static_window.update(json_syntax) 374 | switch_window = self.static_window 375 | else: 376 | string = self.static_window.file_to_string( 377 | file_path=file_path, max_lines=self.config_object.max_lines 378 | ) 379 | syntax = self.static_window.text_to_syntax(text=string, file_path=file_path) 380 | self.static_window.update(syntax) 381 | switch_window = self.static_window 382 | self.switch_window(switch_window) 383 | active_widget = self.get_active_widget() 384 | if scroll_home: 385 | if active_widget is self.vim_scroll: 386 | self.vim_scroll.scroll_home(animate=False) 387 | else: 388 | switch_window.scroll_home(animate=False) 389 | if active_widget is self.vim_scroll: 390 | self.app.sub_title = str(file_path) + f" [{self.static_window.theme}]" 391 | else: 392 | self.app.sub_title = str(file_path) 393 | self.rendered_file = file_path 394 | 395 | def next_theme(self) -> str | None: 396 | """ 397 | Switch to the next theme 398 | """ 399 | if self.get_active_widget() is not self.vim_scroll: 400 | return None 401 | current_index = favorite_themes.index(self.static_window.theme) 402 | next_theme = favorite_themes[(current_index + 1) % len(favorite_themes)] 403 | self.static_window.theme = next_theme 404 | self.app.sub_title = str(self.rendered_file) + f" [{self.static_window.theme}]" 405 | return next_theme 406 | 407 | def action_toggle_files(self) -> None: 408 | """ 409 | Called in response to key binding. 410 | """ 411 | self.show_tree = not self.show_tree 412 | if not self.show_tree: 413 | self.focus() 414 | 415 | def watch_show_tree(self, show_tree: bool) -> None: 416 | """ 417 | Called when show_tree is modified. 418 | """ 419 | self.set_class(show_tree, "-show-tree") 420 | -------------------------------------------------------------------------------- /docs/_static/browsr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/docs/_static/browsr.png -------------------------------------------------------------------------------- /docs/_static/browsr_no_label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/docs/_static/browsr_no_label.png -------------------------------------------------------------------------------- /docs/_static/screenshot_datatable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/docs/_static/screenshot_datatable.png -------------------------------------------------------------------------------- /docs/_static/screenshot_markdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/docs/_static/screenshot_markdown.png -------------------------------------------------------------------------------- /docs/_static/screenshot_mona_lisa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/docs/_static/screenshot_mona_lisa.png -------------------------------------------------------------------------------- /docs/_static/screenshot_utils.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/docs/_static/screenshot_utils.png -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface 2 | 3 | ::: mkdocs-click 4 | :module: browsr.__main__ 5 | :command: browsr 6 | :prog_name: browsr 7 | :style: table 8 | :list_subcommands: True 9 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Environment Setup 4 | 5 | > TIP: **pipx** 6 | > 7 | > This documentaion uses [pipx] to 8 | > install and manage non-project command line tools like `hatch` and 9 | > `pre-commit`. If you don't already have `pipx` installed, make sure to 10 | > see their [documentation](https://pypa.github.io/pipx/installation/). 11 | > If you prefer not to use `pipx`, you can use `pip` instead. 12 | 13 | 1. Install [hatch](https://hatch.pypa.io/latest/) 14 | 15 | ```shell 16 | pipx install hatch 17 | ``` 18 | 19 | > NOTE: **pre-commit** 20 | > 21 | > Hatch will attempt to set up pre-commit hooks for you using 22 | > [pre-commit]. If you don't already, 23 | > make sure to install pre-commit as well: `pipx install pre-commit` 24 | 25 | 2. Build the Virtual Environment 26 | 27 | ```shell 28 | hatch env create 29 | ``` 30 | 31 | 3. If you need to, you can link hatch's virtual environment to your IDE. 32 | It's located in the `.venv` directory at the root of the project. 33 | 34 | 4. Activate the Virtual Environment 35 | 36 | ```shell 37 | hatch shell 38 | ``` 39 | 40 | ## Using Hatch 41 | 42 | ### Hatch Cheat Sheet 43 | 44 | | Command Description | Command | Notes | 45 | | ------------------------------ | --------------------------- | ---------------------------------------------------------- | 46 | | Run Tests | `hatch run cov` | Runs tests with `pytest` and `coverage` | 47 | | Run Formatting | `hatch run lint:fmt` | Runs `ruff` code formatter | 48 | | Run Linting | `hatch run lint:all` | Runs `ruff` and `mypy` linters / type checkers | 49 | | Run Type Checking | `hatch run lint:typing` | Runs `mypy` type checker | 50 | | Update Requirements Lock Files | `hatch run gen:reqs` | Updating lock file using `pip-compile` | 51 | | Upgrade Dependencies | `hatch run gen:reqs-update` | Updating lock file using `pip-compile` and `--update` flag | 52 | | Serve the Documentation | `hatch run docs:serve` | Serve the documentation using MkDocs | 53 | | Run the `pre-commit` Hooks | `hatch run lint:precommit` | Runs the `pre-commit` hooks on all files | 54 | 55 | ### Hatch Explanation 56 | 57 | Hatch is a Python package manager. Its most basic use is as a standardized build-system. 58 | However, hatch also has some extra features which this project takes advantage of. 59 | These features include virtual environment management and the organization of common 60 | scripts like linting and testing. All the operations in hatch take place in one 61 | of its managed virtual environments. 62 | 63 | Hatch has a variety of environments, to see them simply ask hatch: 64 | 65 | ```bash exec="on" result="markdown" source="tabbed-left" tabs="hatch CLI|Output" 66 | hatch env show 67 | ``` 68 | 69 | That above command will tell you that there are five environments that 70 | you can use: 71 | 72 | - `default` 73 | - `docs` 74 | - `gen` 75 | - `lint` 76 | - `test` 77 | 78 | Each of these environments has a set of commands that you can run. 79 | To see the commands for a specific environment, run: 80 | 81 | ```bash exec="on" result="markdown" source="tabbed-left" tabs="hatch CLI|Output" 82 | hatch env show default 83 | ``` 84 | 85 | Here we can see that the `default` environment has the following commands: 86 | 87 | - `cov` 88 | - `test` 89 | 90 | The one that we're interested in is `cov`, which will run the tests 91 | for the project. 92 | 93 | ```bash 94 | hatch run cov 95 | ``` 96 | 97 | Since `cov` is in the default environment, we can run it without 98 | specifying the environment. However, to run the `serve` command in the 99 | `docs` environment, we need to specify the environment: 100 | 101 | ```bash 102 | hatch run docs:serve 103 | ``` 104 | 105 | You can see what scripts are available using the `env show` command 106 | 107 | ```bash exec="on" result="markdown" source="tabbed-left" tabs="hatch CLI|Output" 108 | hatch env show docs 109 | ``` 110 | 111 | ## Committing Code 112 | 113 | This project uses [pre-commit] to run a set of 114 | checks on the code before it is committed. The pre-commit hooks are 115 | installed by hatch automatically when you run it for the first time. 116 | 117 | This project uses [semantic-versioning] standards, managed by [semantic-release]. 118 | Releases for this project are handled entirely by CI/CD via pull requests being 119 | merged into the `main` branch. Contributions follow the [gitmoji] standards 120 | with [conventional commits]. 121 | 122 | While you can denote other changes on your commit messages with [gitmoji], the following 123 | commit message emoji prefixes are the only ones to trigger new releases: 124 | 125 | | Emoji | Shortcode | Description | Semver | 126 | | ----- | ------------- | --------------------------- | ------ | 127 | | 💥 | \:boom\: | Introduce breaking changes. | Major | 128 | | ✨ | \:sparkles\: | Introduce new features. | Minor | 129 | | 🐛 | \:bug\: | Fix a bug. | Patch | 130 | | 🚑 | \:ambulance\: | Critical hotfix. | Patch | 131 | | 🔒 | \:lock\: | Fix security issues. | Patch | 132 | 133 | Most features can be squash merged into a single commit on a pull-request. 134 | When merging multiple commits, they will be summarized into a single release. 135 | 136 | If you're working on a new feature, your commit message might look like: 137 | 138 | ```text 139 | ✨ New Feature Description 140 | ``` 141 | 142 | Bug fix commits would look like this: 143 | 144 | ```text 145 | 🐛 Bug Fix Description 146 | ``` 147 | 148 | If you're working on a feature that introduces breaking changes, your 149 | commit message might look like: 150 | 151 | ```text 152 | 💥 Breaking Change Description 153 | ``` 154 | 155 | Other commits that don't trigger a release might look like this: 156 | 157 | ```text 158 | 📝 Documentation Update Description 159 | 👷 CI/CD Update Description 160 | 🧪 Testing Changes Description 161 | 🚚 Moving/Renaming Description 162 | ⬆️ Dependency Upgrade Description 163 | ``` 164 | 165 | ### Pre-Releases 166 | 167 | [semantic-release] supports pre-releases. To trigger a pre-release, you 168 | would merge your pull request into an `alpha` or `beta` branch. 169 | 170 | ### Specific Release Versions 171 | 172 | In some cases you need more advanced control around what kind of release you 173 | need to create. If you need to release a specific version, you can do so by creating a 174 | new branch with the version number as the branch name. For example, if the 175 | current version is `2.3.2`, but you need to release a fix as `1.2.5`, you 176 | would create a branch named `1.2.x` and merge your changes into that branch. 177 | 178 | See the [semantic-release documentation] for more information about 179 | branch based releases and other advanced release cases. 180 | 181 | [pipx]: https://pypa.github.io/pipx/ 182 | [pre-commit]: https://pre-commit.com/ 183 | [gitmoji]: https://gitmoji.dev/ 184 | [conventional commits]: https://www.conventionalcommits.org/en/v1.0.0/ 185 | [semantic-release]: https://github.com/semantic-release/semantic-release 186 | [semantic-versioning]: https://semver.org/ 187 | [semantic-release documentation]: https://semantic-release.gitbook.io/semantic-release/usage/configuration#branches 188 | -------------------------------------------------------------------------------- /docs/gen_ref_pages.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate the code reference pages and navigation. 3 | """ 4 | 5 | import logging 6 | from pathlib import Path 7 | 8 | import mkdocs_gen_files 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | project_dir = Path(__file__).resolve().parent.parent 13 | source_code = project_dir.joinpath("browsr") 14 | nav = mkdocs_gen_files.Nav() 15 | 16 | for path in sorted(source_code.rglob("*.py")): 17 | module_path = path.relative_to(project_dir).with_suffix("") 18 | doc_path = path.relative_to(source_code).with_suffix(".md") 19 | full_doc_path = Path("reference", doc_path) 20 | 21 | parts = tuple(module_path.parts) 22 | if parts[-1] == "__init__": 23 | parts = parts[:-1] 24 | doc_path = doc_path.with_name("index.md") 25 | full_doc_path = full_doc_path.with_name("index.md") 26 | elif parts[-1] == "__main__": 27 | continue 28 | 29 | nav[parts] = doc_path.as_posix() 30 | 31 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 32 | ident = ".".join(parts) 33 | fd.write(f"::: {ident}") 34 | 35 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 36 | 37 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 38 | nav_file.writelines(nav.build_literate_nav()) 39 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | browsr 4 | 5 |
6 | 7 |

8 | a pleasant file explorer in your terminal supporting all filesystems 9 |

10 | 11 |

12 | PyPI 13 | PyPI - Python Version 14 | GitHub License 15 | docs 16 | Testing Status 17 | Hatch project 18 | Ruff 19 | pre-commit 20 | semantic-release 21 | Gitmoji 22 |

23 | 24 | **`browsr`** 🗂️ is a pleasant **file explorer** in your terminal. It's a command line **TUI** 25 | (text-based user interface) application that empowers you to browse the contents of local 26 | and remote filesystems with your keyboard or mouse. 27 | 28 | You can quickly navigate through directories and peek at files whether they're hosted **locally**, 29 | in **GitHub**, over **SSH**, in **AWS S3**, **Google Cloud Storage**, or **Azure Blob Storage**. View code files 30 | with syntax highlighting, format JSON files, render images, convert data files to navigable 31 | datatables, and more. 32 | 33 | 61 | 62 | 63 |
64 |
65 | Image 1 66 |
67 |
68 | Image 2 69 |
70 |
71 | Image 3 72 |
73 |
74 | Image 4 75 |
76 |
77 | 78 | 81 | 82 | 88 | 89 | 90 |
91 | Screen Recording 92 | 96 |
97 | 98 | ## Installation 99 | 100 | It's recommended to use [pipx](https://pypa.github.io/pipx/) instead of pip. `pipx` installs the package in 101 | an isolated environment and makes it available everywhere. If you'd like to use `pip` instead, just replace `pipx` 102 | with `pip` in the below command. 103 | 104 | ```shell 105 | pipx install browsr 106 | ``` 107 | 108 | ### Extra Installation 109 | 110 | If you're looking to use **`browsr`** on remote file systems, like GitHub or AWS S3, you'll need to install the `remote` extra. 111 | If you'd like to browse parquet / feather files, you'll need to install the `data` extra. Or, even simpler, 112 | you can install the `all` extra to get all the extras. 113 | 114 | ```shell 115 | pipx install "browsr[all]" 116 | ``` 117 | 118 | ## Usage 119 | 120 | Simply give **`browsr`** a path to a local or remote file / directory. 121 | [Check out the Documentation](https://juftin.com/browsr/) for more information 122 | about the file systems supported. 123 | 124 | ### Local 125 | 126 | ```shell 127 | browsr ~/Downloads/ 128 | ``` 129 | 130 | ### GitHub 131 | 132 | ``` 133 | browsr github://juftin:browsr 134 | ``` 135 | 136 | ``` 137 | export GITHUB_TOKEN="ghp_1234567890" 138 | browsr github://juftin:browsr-private@main 139 | ``` 140 | 141 | ### Cloud 142 | 143 | ```shell 144 | browsr s3://my-bucket 145 | ``` 146 | 147 | ** _Currently AWS S3, Google Cloud Storage, and Azure Blob Storage are supported._ 148 | 149 | ### SSH / SFTP 150 | 151 | ```shell 152 | browsr ssh://username@example.com:22 153 | ``` 154 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | # schema: https://squidfunk.github.io/mkdocs-material/schema.json 2 | 3 | site_name: browsr 4 | nav: 5 | - browsr 🗂️: index.md 6 | - Command Line Interface ⌨️: cli.md 7 | - Contributing 🤝: contributing.md 8 | - API Documentation 🤖: reference/ 9 | theme: 10 | favicon: https://raw.githubusercontent.com/juftin/browsr/main/docs/_static/browsr_no_label.png 11 | logo: https://raw.githubusercontent.com/juftin/browsr/main/docs/_static/browsr_no_label.png 12 | name: material 13 | features: 14 | - navigation.tracking 15 | - content.code.annotate 16 | - content.code.copy 17 | - navigation.indexes 18 | palette: 19 | - media: "(prefers-color-scheme: light)" 20 | scheme: default 21 | accent: purple 22 | toggle: 23 | icon: material/weather-sunny 24 | name: Switch to dark mode 25 | - media: "(prefers-color-scheme: dark)" 26 | scheme: slate 27 | primary: black 28 | toggle: 29 | icon: material/weather-night 30 | name: Switch to light mode 31 | repo_url: https://github.com/juftin/browsr 32 | repo_name: browsr 33 | edit_uri: blob/main/docs/ 34 | site_author: Justin Flannery 35 | remote_branch: gh-pages 36 | copyright: Copyright © 2023 Justin Flannery 37 | extra: 38 | generator: false 39 | exclude_docs: | 40 | gen_pages.py 41 | markdown_extensions: 42 | - toc: 43 | permalink: "#" 44 | - pymdownx.snippets 45 | - pymdownx.magiclink 46 | - attr_list 47 | - md_in_html 48 | - pymdownx.highlight: 49 | anchor_linenums: true 50 | - pymdownx.inlinehilite 51 | - pymdownx.superfences 52 | - markdown.extensions.attr_list 53 | - pymdownx.keys 54 | - pymdownx.tasklist 55 | - pymdownx.tilde 56 | - callouts 57 | - pymdownx.details 58 | - mkdocs-click 59 | - pymdownx.emoji 60 | - pymdownx.tabbed: 61 | alternate_style: true 62 | plugins: 63 | - search 64 | - markdown-exec 65 | - autorefs 66 | - gen-files: 67 | scripts: 68 | - docs/gen_ref_pages.py 69 | - literate-nav: 70 | nav_file: SUMMARY.md 71 | - section-index: 72 | - mkdocstrings: 73 | handlers: 74 | python: 75 | import: 76 | - https://docs.python.org/3/objects.inv 77 | - https://numpy.org/doc/stable/objects.inv 78 | - https://pandas.pydata.org/docs/objects.inv 79 | options: 80 | docstring_style: numpy 81 | filters: [] 82 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling"] 4 | 5 | [project] 6 | authors = [ 7 | {name = "Justin Flannery", email = "juftin@juftin.com"} 8 | ] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Operating System :: OS Independent", 12 | "Programming Language :: Python", 13 | "Programming Language :: Python :: 3.8", 14 | "Programming Language :: Python :: 3.9", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: Implementation :: CPython", 19 | "Programming Language :: Python :: Implementation :: PyPy" 20 | ] 21 | dependencies = [ 22 | "art~=6.1", 23 | "click~=8.1.7", 24 | "pandas>2,<3", 25 | "rich~=13.7.1", 26 | "rich-click~=1.7.4", 27 | "rich-pixels~=2.2.0", 28 | "textual==0.53.1", 29 | "textual-universal-directorytree~=1.5.0", 30 | "universal-pathlib~=0.2.2", 31 | "Pillow>=10.2.0", 32 | "PyMuPDF~=1.23.26", 33 | "pyperclip~=1.8.2" 34 | ] 35 | description = "TUI File Browser App" 36 | dynamic = ["version"] 37 | keywords = [] 38 | license = "MIT" 39 | name = "browsr" 40 | readme = "README.md" 41 | requires-python = ">=3.8,<4.0" 42 | 43 | [project.optional-dependencies] 44 | all = [ 45 | "pyarrow~=15.0.2", 46 | "textual-universal-directorytree[remote]~=1.5.0" 47 | ] 48 | data = [ 49 | "pyarrow~=15.0.2" 50 | ] 51 | parquet = [ 52 | "pyarrow~=15.0.2" 53 | ] 54 | remote = [ 55 | "textual-universal-directorytree[remote]~=1.5.0" 56 | ] 57 | 58 | [project.scripts] 59 | browsr = "browsr.__main__:browsr" 60 | 61 | [project.urls] 62 | Documentation = "https://github.com/juftin/browsr#readme" 63 | Issues = "https://github.com/juftin/browsr/issues" 64 | Source = "https://github.com/juftin/browsr" 65 | 66 | [tool.coverage.paths] 67 | browsr = ["browsr", "*/browsr/browsr"] 68 | tests = ["tests", "*/browsr/tests"] 69 | 70 | [tool.coverage.report] 71 | exclude_lines = [ 72 | "no cov", 73 | "if __name__ == .__main__.:", 74 | "if TYPE_CHECKING:" 75 | ] 76 | 77 | [tool.coverage.run] 78 | branch = true 79 | omit = ["browsr/__about__.py"] 80 | parallel = true 81 | source_pkgs = ["browsr", "tests"] 82 | 83 | [tool.hatch.env] 84 | requires = ["hatch-pip-compile", "hatch-mkdocs"] 85 | 86 | [tool.hatch.env.collectors.mkdocs.docs] 87 | path = "mkdocs.yaml" 88 | 89 | [tool.hatch.envs.all] 90 | pip-compile-constraint = "" 91 | template = "test" 92 | 93 | [[tool.hatch.envs.all.matrix]] 94 | python = ["3.8", "3.9", "3.10", "3.11", "3.12"] 95 | 96 | [tool.hatch.envs.default] 97 | features = ["all"] 98 | pip-compile-constraint = "default" 99 | pip-compile-installer = "uv" 100 | pip-compile-resolver = "uv" 101 | post-install-commands = [ 102 | "- pre-commit install" 103 | ] 104 | type = "pip-compile" 105 | 106 | [tool.hatch.envs.default.env-vars] 107 | GITHUB_TOKEN = "{env:GITHUB_TOKEN:placeholder}" 108 | 109 | [tool.hatch.envs.default.scripts] 110 | cov = "hatch run test:cov" 111 | test = "hatch run test:test" 112 | 113 | [tool.hatch.envs.docs] 114 | detached = false 115 | pip-compile-constraint = "default" 116 | pip-compile-installer = "uv" 117 | pip-compile-resolver = "uv" 118 | template = "docs" 119 | type = "pip-compile" 120 | 121 | [tool.hatch.envs.gen] 122 | detached = true 123 | 124 | [tool.hatch.envs.gen.scripts] 125 | release = [ 126 | "npm install --prefix .github/semantic_release/", 127 | "npx --prefix .github/semantic_release/ semantic-release {args:}" 128 | ] 129 | 130 | [tool.hatch.envs.lint] 131 | dependencies = [ 132 | "mypy>=1.9.0", 133 | "ruff~=0.1.7" 134 | ] 135 | detached = true 136 | type = "pip-compile" 137 | 138 | [tool.hatch.envs.lint.scripts] 139 | all = [ 140 | "style", 141 | "typing" 142 | ] 143 | fmt = [ 144 | "ruff format {args:.}", 145 | "ruff --fix {args:.}", 146 | "style" 147 | ] 148 | precommit = [ 149 | "pre-commit run --all-files" 150 | ] 151 | style = [ 152 | "ruff {args:.}", 153 | "ruff format --check --diff {args:.}" 154 | ] 155 | typing = "mypy --install-types --non-interactive {args:browsr tests}" 156 | 157 | [tool.hatch.envs.test] 158 | dependencies = [ 159 | "pytest", 160 | "pytest-cov", 161 | "pytest-vcr~=1.0.2", 162 | "textual-dev~=1.4.0", 163 | "pytest-textual-snapshot", 164 | "pytest-asyncio" 165 | ] 166 | 167 | [tool.hatch.envs.test.scripts] 168 | cov = "pytest --cov --cov-config=pyproject.toml {args:tests}" 169 | test = "pytest {args:tests}" 170 | 171 | [tool.hatch.version] 172 | path = "browsr/__about__.py" 173 | 174 | [tool.mypy] 175 | check_untyped_defs = true 176 | disallow_any_generics = true 177 | disallow_untyped_defs = true 178 | follow_imports = "silent" 179 | ignore_missing_imports = true 180 | no_implicit_reexport = true 181 | strict_optional = true 182 | warn_redundant_casts = true 183 | warn_unused_ignores = true 184 | 185 | [tool.ruff] 186 | ignore = [ 187 | # Ignore checks for possible passwords 188 | "S105", 189 | "S106", 190 | "S107", 191 | # Ignore complexity 192 | "C901", 193 | "PLR0911", 194 | "PLR0912", 195 | "PLR0913", 196 | "PLR0915", 197 | # Boolean-typed positional argument in function definition 198 | "FBT001", 199 | # Boolean default positional argument in function definition 200 | "FBT002", 201 | # Allow boolean positional values in function calls, like `dict.get(... True)` 202 | "FBT003", 203 | # Exception must not use a string literal, assign to variable first 204 | "EM101" 205 | ] 206 | line-length = 88 207 | select = [ 208 | "A", # flake8-builtins 209 | "ARG", # flake8-unused-arguments 210 | "B", # flake8-bugbear 211 | "C", # mccabe 212 | "DTZ", # flake8-datetimez 213 | "E", # pycodestyle (Error) 214 | "EM", # flake8-errmsg 215 | "F", # Pyflakes 216 | "FBT", # flake8-boolean-trap 217 | "I", # isort 218 | "ICN", # flake8-import-conventions 219 | "N", # pep8-naming 220 | "PLC", # Pylint (Convention message) 221 | "PLE", # Pylint (Error message) 222 | "PLR", # Pylint (Refactor message) 223 | "PLW", # Pylint (Warning message) 224 | "Q", # flake8-quotes 225 | "RUF", # Ruff-specific rules 226 | "S", # flake8-bandit 227 | "T", # flake8-debugger (T10) and flake8-print (T20) 228 | "TID", # flake8-tidy-imports 229 | "UP", # pyupgrade 230 | "W", # pycodestyle (Warning) 231 | "YTT" # flake8-2020 232 | ] 233 | target-version = "py38" 234 | 235 | [tool.ruff.flake8-tidy-imports] 236 | ban-relative-imports = "all" 237 | 238 | [tool.ruff.isort] 239 | known-first-party = ["browsr"] 240 | 241 | [tool.ruff.per-file-ignores] 242 | # Tests can use magic values, assertions, and relative imports 243 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 244 | 245 | [tool.ruff.pydocstyle] 246 | convention = "numpy" 247 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.11 3 | # 4 | # - art~=6.1 5 | # - click~=8.1.7 6 | # - pandas<3,>2 7 | # - pillow>=10.2.0 8 | # - pymupdf~=1.23.26 9 | # - pyperclip~=1.8.2 10 | # - rich-click~=1.7.4 11 | # - rich-pixels~=2.2.0 12 | # - rich~=13.7.1 13 | # - textual-universal-directorytree~=1.5.0 14 | # - textual==0.53.1 15 | # - universal-pathlib~=0.2.2 16 | # - pyarrow~=15.0.2 17 | # - textual-universal-directorytree[remote]~=1.5.0 18 | # 19 | 20 | adlfs==2024.2.0 21 | # via textual-universal-directorytree 22 | aiobotocore==2.12.1 23 | # via s3fs 24 | aiohttp==3.9.3 25 | # via 26 | # adlfs 27 | # aiobotocore 28 | # gcsfs 29 | # s3fs 30 | # textual-universal-directorytree 31 | aioitertools==0.11.0 32 | # via aiobotocore 33 | aiosignal==1.3.1 34 | # via aiohttp 35 | art==6.1 36 | attrs==23.2.0 37 | # via aiohttp 38 | azure-core==1.30.1 39 | # via 40 | # adlfs 41 | # azure-identity 42 | # azure-storage-blob 43 | azure-datalake-store==0.0.53 44 | # via adlfs 45 | azure-identity==1.15.0 46 | # via adlfs 47 | azure-storage-blob==12.19.1 48 | # via adlfs 49 | bcrypt==4.1.2 50 | # via paramiko 51 | botocore==1.34.51 52 | # via aiobotocore 53 | cachetools==5.3.3 54 | # via google-auth 55 | certifi==2024.2.2 56 | # via requests 57 | cffi==1.16.0 58 | # via 59 | # azure-datalake-store 60 | # cryptography 61 | # pynacl 62 | charset-normalizer==3.3.2 63 | # via requests 64 | click==8.1.7 65 | # via rich-click 66 | cryptography==42.0.5 67 | # via 68 | # azure-identity 69 | # azure-storage-blob 70 | # msal 71 | # paramiko 72 | # pyjwt 73 | decorator==5.1.1 74 | # via gcsfs 75 | frozenlist==1.4.1 76 | # via 77 | # aiohttp 78 | # aiosignal 79 | fsspec==2024.3.1 80 | # via 81 | # adlfs 82 | # gcsfs 83 | # s3fs 84 | # universal-pathlib 85 | gcsfs==2024.3.1 86 | # via textual-universal-directorytree 87 | google-api-core==2.17.1 88 | # via 89 | # google-cloud-core 90 | # google-cloud-storage 91 | google-auth==2.28.2 92 | # via 93 | # gcsfs 94 | # google-api-core 95 | # google-auth-oauthlib 96 | # google-cloud-core 97 | # google-cloud-storage 98 | google-auth-oauthlib==1.2.0 99 | # via gcsfs 100 | google-cloud-core==2.4.1 101 | # via google-cloud-storage 102 | google-cloud-storage==2.16.0 103 | # via gcsfs 104 | google-crc32c==1.5.0 105 | # via 106 | # google-cloud-storage 107 | # google-resumable-media 108 | google-resumable-media==2.7.0 109 | # via google-cloud-storage 110 | googleapis-common-protos==1.63.0 111 | # via google-api-core 112 | idna==3.6 113 | # via 114 | # requests 115 | # yarl 116 | isodate==0.6.1 117 | # via azure-storage-blob 118 | jmespath==1.0.1 119 | # via botocore 120 | linkify-it-py==2.0.3 121 | # via markdown-it-py 122 | markdown-it-py==3.0.0 123 | # via 124 | # mdit-py-plugins 125 | # rich 126 | # textual 127 | mdit-py-plugins==0.4.0 128 | # via markdown-it-py 129 | mdurl==0.1.2 130 | # via markdown-it-py 131 | msal==1.28.0 132 | # via 133 | # azure-datalake-store 134 | # azure-identity 135 | # msal-extensions 136 | msal-extensions==1.1.0 137 | # via azure-identity 138 | multidict==6.0.5 139 | # via 140 | # aiohttp 141 | # yarl 142 | numpy==1.26.4 143 | # via 144 | # pandas 145 | # pyarrow 146 | oauthlib==3.2.2 147 | # via requests-oauthlib 148 | packaging==24.0 149 | # via msal-extensions 150 | pandas==2.2.1 151 | paramiko==3.4.0 152 | # via textual-universal-directorytree 153 | pillow==10.2.0 154 | # via rich-pixels 155 | portalocker==2.8.2 156 | # via msal-extensions 157 | protobuf==4.25.3 158 | # via 159 | # google-api-core 160 | # googleapis-common-protos 161 | pyarrow==15.0.2 162 | pyasn1==0.5.1 163 | # via 164 | # pyasn1-modules 165 | # rsa 166 | pyasn1-modules==0.3.0 167 | # via google-auth 168 | pycparser==2.21 169 | # via cffi 170 | pygments==2.17.2 171 | # via rich 172 | pyjwt==2.8.0 173 | # via msal 174 | pymupdf==1.23.26 175 | pymupdfb==1.23.22 176 | # via pymupdf 177 | pynacl==1.5.0 178 | # via paramiko 179 | pyperclip==1.8.2 180 | python-dateutil==2.9.0.post0 181 | # via 182 | # botocore 183 | # pandas 184 | pytz==2024.1 185 | # via pandas 186 | requests==2.31.0 187 | # via 188 | # azure-core 189 | # azure-datalake-store 190 | # gcsfs 191 | # google-api-core 192 | # google-cloud-storage 193 | # msal 194 | # requests-oauthlib 195 | # textual-universal-directorytree 196 | requests-oauthlib==1.4.0 197 | # via google-auth-oauthlib 198 | rich==13.7.1 199 | # via 200 | # rich-click 201 | # rich-pixels 202 | # textual 203 | rich-click==1.7.4 204 | rich-pixels==2.2.0 205 | rsa==4.9 206 | # via google-auth 207 | s3fs==2024.3.1 208 | # via textual-universal-directorytree 209 | six==1.16.0 210 | # via 211 | # azure-core 212 | # isodate 213 | # python-dateutil 214 | textual==0.53.1 215 | # via textual-universal-directorytree 216 | textual-universal-directorytree==1.5.0 217 | typing-extensions==4.10.0 218 | # via 219 | # azure-core 220 | # azure-storage-blob 221 | # rich-click 222 | # textual 223 | tzdata==2024.1 224 | # via pandas 225 | uc-micro-py==1.0.3 226 | # via linkify-it-py 227 | universal-pathlib==0.2.2 228 | # via textual-universal-directorytree 229 | urllib3==2.0.7 230 | # via 231 | # botocore 232 | # requests 233 | wrapt==1.16.0 234 | # via aiobotocore 235 | yarl==1.9.4 236 | # via aiohttp 237 | -------------------------------------------------------------------------------- /requirements/requirements-all.py3.10.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.10 3 | # 4 | # - pytest 5 | # - pytest-cov 6 | # - pytest-vcr~=1.0.2 7 | # - textual-dev~=1.4.0 8 | # - pytest-textual-snapshot 9 | # - pytest-asyncio 10 | # - art~=6.1 11 | # - click~=8.1.7 12 | # - pandas<3,>2 13 | # - pillow>=10.2.0 14 | # - pymupdf~=1.23.26 15 | # - pyperclip~=1.8.2 16 | # - rich-click~=1.7.4 17 | # - rich-pixels~=2.2.0 18 | # - rich~=13.7.1 19 | # - textual-universal-directorytree~=1.5.0 20 | # - textual==0.53.1 21 | # - universal-pathlib~=0.2.2 22 | # - pyarrow~=15.0.2 23 | # - textual-universal-directorytree[remote]~=1.5.0 24 | # 25 | 26 | adlfs==2024.2.0 27 | # via textual-universal-directorytree 28 | aiobotocore==2.12.1 29 | # via s3fs 30 | aiohttp==3.9.3 31 | # via 32 | # adlfs 33 | # aiobotocore 34 | # gcsfs 35 | # s3fs 36 | # textual-dev 37 | # textual-universal-directorytree 38 | aioitertools==0.11.0 39 | # via aiobotocore 40 | aiosignal==1.3.1 41 | # via aiohttp 42 | art==6.1 43 | async-timeout==4.0.3 44 | # via aiohttp 45 | attrs==23.2.0 46 | # via aiohttp 47 | azure-core==1.30.1 48 | # via 49 | # adlfs 50 | # azure-identity 51 | # azure-storage-blob 52 | azure-datalake-store==0.0.53 53 | # via adlfs 54 | azure-identity==1.15.0 55 | # via adlfs 56 | azure-storage-blob==12.19.1 57 | # via adlfs 58 | bcrypt==4.1.2 59 | # via paramiko 60 | botocore==1.34.51 61 | # via aiobotocore 62 | cachetools==5.3.3 63 | # via google-auth 64 | certifi==2024.2.2 65 | # via requests 66 | cffi==1.16.0 67 | # via 68 | # azure-datalake-store 69 | # cryptography 70 | # pynacl 71 | charset-normalizer==3.3.2 72 | # via requests 73 | click==8.1.7 74 | # via 75 | # rich-click 76 | # textual-dev 77 | coverage==7.4.4 78 | # via pytest-cov 79 | cryptography==42.0.5 80 | # via 81 | # azure-identity 82 | # azure-storage-blob 83 | # msal 84 | # paramiko 85 | # pyjwt 86 | decorator==5.1.1 87 | # via gcsfs 88 | exceptiongroup==1.2.0 89 | # via pytest 90 | frozenlist==1.4.1 91 | # via 92 | # aiohttp 93 | # aiosignal 94 | fsspec==2024.3.1 95 | # via 96 | # adlfs 97 | # gcsfs 98 | # s3fs 99 | # universal-pathlib 100 | gcsfs==2024.3.1 101 | # via textual-universal-directorytree 102 | google-api-core==2.17.1 103 | # via 104 | # google-cloud-core 105 | # google-cloud-storage 106 | google-auth==2.28.2 107 | # via 108 | # gcsfs 109 | # google-api-core 110 | # google-auth-oauthlib 111 | # google-cloud-core 112 | # google-cloud-storage 113 | google-auth-oauthlib==1.2.0 114 | # via gcsfs 115 | google-cloud-core==2.4.1 116 | # via google-cloud-storage 117 | google-cloud-storage==2.16.0 118 | # via gcsfs 119 | google-crc32c==1.5.0 120 | # via 121 | # google-cloud-storage 122 | # google-resumable-media 123 | google-resumable-media==2.7.0 124 | # via google-cloud-storage 125 | googleapis-common-protos==1.63.0 126 | # via google-api-core 127 | idna==3.6 128 | # via 129 | # requests 130 | # yarl 131 | iniconfig==2.0.0 132 | # via pytest 133 | isodate==0.6.1 134 | # via azure-storage-blob 135 | jinja2==3.1.3 136 | # via pytest-textual-snapshot 137 | jmespath==1.0.1 138 | # via botocore 139 | linkify-it-py==2.0.3 140 | # via markdown-it-py 141 | markdown-it-py==3.0.0 142 | # via 143 | # mdit-py-plugins 144 | # rich 145 | # textual 146 | markupsafe==2.1.5 147 | # via jinja2 148 | mdit-py-plugins==0.4.0 149 | # via markdown-it-py 150 | mdurl==0.1.2 151 | # via markdown-it-py 152 | msal==1.28.0 153 | # via 154 | # azure-datalake-store 155 | # azure-identity 156 | # msal-extensions 157 | msal-extensions==1.1.0 158 | # via azure-identity 159 | msgpack==1.0.8 160 | # via textual-dev 161 | multidict==6.0.5 162 | # via 163 | # aiohttp 164 | # yarl 165 | numpy==1.26.4 166 | # via 167 | # pandas 168 | # pyarrow 169 | oauthlib==3.2.2 170 | # via requests-oauthlib 171 | packaging==24.0 172 | # via 173 | # msal-extensions 174 | # pytest 175 | pandas==2.2.1 176 | paramiko==3.4.0 177 | # via textual-universal-directorytree 178 | pillow==10.2.0 179 | # via rich-pixels 180 | pluggy==1.4.0 181 | # via pytest 182 | portalocker==2.8.2 183 | # via msal-extensions 184 | protobuf==4.25.3 185 | # via 186 | # google-api-core 187 | # googleapis-common-protos 188 | pyarrow==15.0.2 189 | pyasn1==0.5.1 190 | # via 191 | # pyasn1-modules 192 | # rsa 193 | pyasn1-modules==0.3.0 194 | # via google-auth 195 | pycparser==2.21 196 | # via cffi 197 | pygments==2.17.2 198 | # via rich 199 | pyjwt==2.8.0 200 | # via msal 201 | pymupdf==1.23.26 202 | pymupdfb==1.23.22 203 | # via pymupdf 204 | pynacl==1.5.0 205 | # via paramiko 206 | pyperclip==1.8.2 207 | pytest==8.1.1 208 | # via 209 | # pytest-asyncio 210 | # pytest-cov 211 | # pytest-textual-snapshot 212 | # pytest-vcr 213 | # syrupy 214 | pytest-asyncio==0.23.6 215 | pytest-cov==4.1.0 216 | pytest-textual-snapshot==0.4.0 217 | pytest-vcr==1.0.2 218 | python-dateutil==2.9.0.post0 219 | # via 220 | # botocore 221 | # pandas 222 | pytz==2024.1 223 | # via pandas 224 | pyyaml==6.0.1 225 | # via vcrpy 226 | requests==2.31.0 227 | # via 228 | # azure-core 229 | # azure-datalake-store 230 | # gcsfs 231 | # google-api-core 232 | # google-cloud-storage 233 | # msal 234 | # requests-oauthlib 235 | # textual-universal-directorytree 236 | requests-oauthlib==1.4.0 237 | # via google-auth-oauthlib 238 | rich==13.7.1 239 | # via 240 | # pytest-textual-snapshot 241 | # rich-click 242 | # rich-pixels 243 | # textual 244 | rich-click==1.7.4 245 | rich-pixels==2.2.0 246 | rsa==4.9 247 | # via google-auth 248 | s3fs==2024.3.1 249 | # via textual-universal-directorytree 250 | six==1.16.0 251 | # via 252 | # azure-core 253 | # isodate 254 | # python-dateutil 255 | syrupy==4.6.1 256 | # via pytest-textual-snapshot 257 | textual==0.53.1 258 | # via 259 | # pytest-textual-snapshot 260 | # textual-dev 261 | # textual-universal-directorytree 262 | textual-dev==1.4.0 263 | textual-universal-directorytree==1.5.0 264 | tomli==2.0.1 265 | # via 266 | # coverage 267 | # pytest 268 | typing-extensions==4.10.0 269 | # via 270 | # azure-core 271 | # azure-storage-blob 272 | # rich-click 273 | # textual 274 | # textual-dev 275 | tzdata==2024.1 276 | # via pandas 277 | uc-micro-py==1.0.3 278 | # via linkify-it-py 279 | universal-pathlib==0.2.2 280 | # via textual-universal-directorytree 281 | urllib3==2.0.7 282 | # via 283 | # botocore 284 | # requests 285 | vcrpy==6.0.1 286 | # via pytest-vcr 287 | wrapt==1.16.0 288 | # via 289 | # aiobotocore 290 | # vcrpy 291 | yarl==1.9.4 292 | # via 293 | # aiohttp 294 | # vcrpy 295 | -------------------------------------------------------------------------------- /requirements/requirements-all.py3.11.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.11 3 | # 4 | # - pytest 5 | # - pytest-cov 6 | # - pytest-vcr~=1.0.2 7 | # - textual-dev~=1.4.0 8 | # - pytest-textual-snapshot 9 | # - pytest-asyncio 10 | # - art~=6.1 11 | # - click~=8.1.7 12 | # - pandas<3,>2 13 | # - pillow>=10.2.0 14 | # - pymupdf~=1.23.26 15 | # - pyperclip~=1.8.2 16 | # - rich-click~=1.7.4 17 | # - rich-pixels~=2.2.0 18 | # - rich~=13.7.1 19 | # - textual-universal-directorytree~=1.5.0 20 | # - textual==0.53.1 21 | # - universal-pathlib~=0.2.2 22 | # - pyarrow~=15.0.2 23 | # - textual-universal-directorytree[remote]~=1.5.0 24 | # 25 | 26 | adlfs==2024.2.0 27 | # via textual-universal-directorytree 28 | aiobotocore==2.12.1 29 | # via s3fs 30 | aiohttp==3.9.3 31 | # via 32 | # adlfs 33 | # aiobotocore 34 | # gcsfs 35 | # s3fs 36 | # textual-dev 37 | # textual-universal-directorytree 38 | aioitertools==0.11.0 39 | # via aiobotocore 40 | aiosignal==1.3.1 41 | # via aiohttp 42 | art==6.1 43 | attrs==23.2.0 44 | # via aiohttp 45 | azure-core==1.30.1 46 | # via 47 | # adlfs 48 | # azure-identity 49 | # azure-storage-blob 50 | azure-datalake-store==0.0.53 51 | # via adlfs 52 | azure-identity==1.15.0 53 | # via adlfs 54 | azure-storage-blob==12.19.1 55 | # via adlfs 56 | bcrypt==4.1.2 57 | # via paramiko 58 | botocore==1.34.51 59 | # via aiobotocore 60 | cachetools==5.3.3 61 | # via google-auth 62 | certifi==2024.2.2 63 | # via requests 64 | cffi==1.16.0 65 | # via 66 | # azure-datalake-store 67 | # cryptography 68 | # pynacl 69 | charset-normalizer==3.3.2 70 | # via requests 71 | click==8.1.7 72 | # via 73 | # rich-click 74 | # textual-dev 75 | coverage==7.4.4 76 | # via pytest-cov 77 | cryptography==42.0.5 78 | # via 79 | # azure-identity 80 | # azure-storage-blob 81 | # msal 82 | # paramiko 83 | # pyjwt 84 | decorator==5.1.1 85 | # via gcsfs 86 | frozenlist==1.4.1 87 | # via 88 | # aiohttp 89 | # aiosignal 90 | fsspec==2024.3.1 91 | # via 92 | # adlfs 93 | # gcsfs 94 | # s3fs 95 | # universal-pathlib 96 | gcsfs==2024.3.1 97 | # via textual-universal-directorytree 98 | google-api-core==2.17.1 99 | # via 100 | # google-cloud-core 101 | # google-cloud-storage 102 | google-auth==2.28.2 103 | # via 104 | # gcsfs 105 | # google-api-core 106 | # google-auth-oauthlib 107 | # google-cloud-core 108 | # google-cloud-storage 109 | google-auth-oauthlib==1.2.0 110 | # via gcsfs 111 | google-cloud-core==2.4.1 112 | # via google-cloud-storage 113 | google-cloud-storage==2.16.0 114 | # via gcsfs 115 | google-crc32c==1.5.0 116 | # via 117 | # google-cloud-storage 118 | # google-resumable-media 119 | google-resumable-media==2.7.0 120 | # via google-cloud-storage 121 | googleapis-common-protos==1.63.0 122 | # via google-api-core 123 | idna==3.6 124 | # via 125 | # requests 126 | # yarl 127 | iniconfig==2.0.0 128 | # via pytest 129 | isodate==0.6.1 130 | # via azure-storage-blob 131 | jinja2==3.1.3 132 | # via pytest-textual-snapshot 133 | jmespath==1.0.1 134 | # via botocore 135 | linkify-it-py==2.0.3 136 | # via markdown-it-py 137 | markdown-it-py==3.0.0 138 | # via 139 | # mdit-py-plugins 140 | # rich 141 | # textual 142 | markupsafe==2.1.5 143 | # via jinja2 144 | mdit-py-plugins==0.4.0 145 | # via markdown-it-py 146 | mdurl==0.1.2 147 | # via markdown-it-py 148 | msal==1.28.0 149 | # via 150 | # azure-datalake-store 151 | # azure-identity 152 | # msal-extensions 153 | msal-extensions==1.1.0 154 | # via azure-identity 155 | msgpack==1.0.8 156 | # via textual-dev 157 | multidict==6.0.5 158 | # via 159 | # aiohttp 160 | # yarl 161 | numpy==1.26.4 162 | # via 163 | # pandas 164 | # pyarrow 165 | oauthlib==3.2.2 166 | # via requests-oauthlib 167 | packaging==24.0 168 | # via 169 | # msal-extensions 170 | # pytest 171 | pandas==2.2.1 172 | paramiko==3.4.0 173 | # via textual-universal-directorytree 174 | pillow==10.2.0 175 | # via rich-pixels 176 | pluggy==1.4.0 177 | # via pytest 178 | portalocker==2.8.2 179 | # via msal-extensions 180 | protobuf==4.25.3 181 | # via 182 | # google-api-core 183 | # googleapis-common-protos 184 | pyarrow==15.0.2 185 | pyasn1==0.5.1 186 | # via 187 | # pyasn1-modules 188 | # rsa 189 | pyasn1-modules==0.3.0 190 | # via google-auth 191 | pycparser==2.21 192 | # via cffi 193 | pygments==2.17.2 194 | # via rich 195 | pyjwt==2.8.0 196 | # via msal 197 | pymupdf==1.23.26 198 | pymupdfb==1.23.22 199 | # via pymupdf 200 | pynacl==1.5.0 201 | # via paramiko 202 | pyperclip==1.8.2 203 | pytest==8.1.1 204 | # via 205 | # pytest-asyncio 206 | # pytest-cov 207 | # pytest-textual-snapshot 208 | # pytest-vcr 209 | # syrupy 210 | pytest-asyncio==0.23.6 211 | pytest-cov==4.1.0 212 | pytest-textual-snapshot==0.4.0 213 | pytest-vcr==1.0.2 214 | python-dateutil==2.9.0.post0 215 | # via 216 | # botocore 217 | # pandas 218 | pytz==2024.1 219 | # via pandas 220 | pyyaml==6.0.1 221 | # via vcrpy 222 | requests==2.31.0 223 | # via 224 | # azure-core 225 | # azure-datalake-store 226 | # gcsfs 227 | # google-api-core 228 | # google-cloud-storage 229 | # msal 230 | # requests-oauthlib 231 | # textual-universal-directorytree 232 | requests-oauthlib==1.4.0 233 | # via google-auth-oauthlib 234 | rich==13.7.1 235 | # via 236 | # pytest-textual-snapshot 237 | # rich-click 238 | # rich-pixels 239 | # textual 240 | rich-click==1.7.4 241 | rich-pixels==2.2.0 242 | rsa==4.9 243 | # via google-auth 244 | s3fs==2024.3.1 245 | # via textual-universal-directorytree 246 | six==1.16.0 247 | # via 248 | # azure-core 249 | # isodate 250 | # python-dateutil 251 | syrupy==4.6.1 252 | # via pytest-textual-snapshot 253 | textual==0.53.1 254 | # via 255 | # pytest-textual-snapshot 256 | # textual-dev 257 | # textual-universal-directorytree 258 | textual-dev==1.4.0 259 | textual-universal-directorytree==1.5.0 260 | typing-extensions==4.10.0 261 | # via 262 | # azure-core 263 | # azure-storage-blob 264 | # rich-click 265 | # textual 266 | # textual-dev 267 | tzdata==2024.1 268 | # via pandas 269 | uc-micro-py==1.0.3 270 | # via linkify-it-py 271 | universal-pathlib==0.2.2 272 | # via textual-universal-directorytree 273 | urllib3==2.0.7 274 | # via 275 | # botocore 276 | # requests 277 | vcrpy==6.0.1 278 | # via pytest-vcr 279 | wrapt==1.16.0 280 | # via 281 | # aiobotocore 282 | # vcrpy 283 | yarl==1.9.4 284 | # via 285 | # aiohttp 286 | # vcrpy 287 | -------------------------------------------------------------------------------- /requirements/requirements-all.py3.12.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.12 3 | # 4 | # - pytest 5 | # - pytest-cov 6 | # - pytest-vcr~=1.0.2 7 | # - textual-dev~=1.4.0 8 | # - pytest-textual-snapshot 9 | # - pytest-asyncio 10 | # - art~=6.1 11 | # - click~=8.1.7 12 | # - pandas<3,>2 13 | # - pillow>=10.2.0 14 | # - pymupdf~=1.23.26 15 | # - pyperclip~=1.8.2 16 | # - rich-click~=1.7.4 17 | # - rich-pixels~=2.2.0 18 | # - rich~=13.7.1 19 | # - textual-universal-directorytree~=1.5.0 20 | # - textual==0.53.1 21 | # - universal-pathlib~=0.2.2 22 | # - pyarrow~=15.0.2 23 | # - textual-universal-directorytree[remote]~=1.5.0 24 | # 25 | 26 | adlfs==2024.2.0 27 | # via textual-universal-directorytree 28 | aiobotocore==2.12.1 29 | # via s3fs 30 | aiohttp==3.9.3 31 | # via 32 | # adlfs 33 | # aiobotocore 34 | # gcsfs 35 | # s3fs 36 | # textual-dev 37 | # textual-universal-directorytree 38 | aioitertools==0.11.0 39 | # via aiobotocore 40 | aiosignal==1.3.1 41 | # via aiohttp 42 | art==6.1 43 | attrs==23.2.0 44 | # via aiohttp 45 | azure-core==1.30.1 46 | # via 47 | # adlfs 48 | # azure-identity 49 | # azure-storage-blob 50 | azure-datalake-store==0.0.53 51 | # via adlfs 52 | azure-identity==1.15.0 53 | # via adlfs 54 | azure-storage-blob==12.19.1 55 | # via adlfs 56 | bcrypt==4.1.2 57 | # via paramiko 58 | botocore==1.34.51 59 | # via aiobotocore 60 | cachetools==5.3.3 61 | # via google-auth 62 | certifi==2024.2.2 63 | # via requests 64 | cffi==1.16.0 65 | # via 66 | # azure-datalake-store 67 | # cryptography 68 | # pynacl 69 | charset-normalizer==3.3.2 70 | # via requests 71 | click==8.1.7 72 | # via 73 | # rich-click 74 | # textual-dev 75 | coverage==7.4.4 76 | # via pytest-cov 77 | cryptography==42.0.5 78 | # via 79 | # azure-identity 80 | # azure-storage-blob 81 | # msal 82 | # paramiko 83 | # pyjwt 84 | decorator==5.1.1 85 | # via gcsfs 86 | frozenlist==1.4.1 87 | # via 88 | # aiohttp 89 | # aiosignal 90 | fsspec==2024.3.1 91 | # via 92 | # adlfs 93 | # gcsfs 94 | # s3fs 95 | # universal-pathlib 96 | gcsfs==2024.3.1 97 | # via textual-universal-directorytree 98 | google-api-core==2.17.1 99 | # via 100 | # google-cloud-core 101 | # google-cloud-storage 102 | google-auth==2.28.2 103 | # via 104 | # gcsfs 105 | # google-api-core 106 | # google-auth-oauthlib 107 | # google-cloud-core 108 | # google-cloud-storage 109 | google-auth-oauthlib==1.2.0 110 | # via gcsfs 111 | google-cloud-core==2.4.1 112 | # via google-cloud-storage 113 | google-cloud-storage==2.16.0 114 | # via gcsfs 115 | google-crc32c==1.5.0 116 | # via 117 | # google-cloud-storage 118 | # google-resumable-media 119 | google-resumable-media==2.7.0 120 | # via google-cloud-storage 121 | googleapis-common-protos==1.63.0 122 | # via google-api-core 123 | idna==3.6 124 | # via 125 | # requests 126 | # yarl 127 | iniconfig==2.0.0 128 | # via pytest 129 | isodate==0.6.1 130 | # via azure-storage-blob 131 | jinja2==3.1.3 132 | # via pytest-textual-snapshot 133 | jmespath==1.0.1 134 | # via botocore 135 | linkify-it-py==2.0.3 136 | # via markdown-it-py 137 | markdown-it-py==3.0.0 138 | # via 139 | # mdit-py-plugins 140 | # rich 141 | # textual 142 | markupsafe==2.1.5 143 | # via jinja2 144 | mdit-py-plugins==0.4.0 145 | # via markdown-it-py 146 | mdurl==0.1.2 147 | # via markdown-it-py 148 | msal==1.28.0 149 | # via 150 | # azure-datalake-store 151 | # azure-identity 152 | # msal-extensions 153 | msal-extensions==1.1.0 154 | # via azure-identity 155 | msgpack==1.0.8 156 | # via textual-dev 157 | multidict==6.0.5 158 | # via 159 | # aiohttp 160 | # yarl 161 | numpy==1.26.4 162 | # via 163 | # pandas 164 | # pyarrow 165 | oauthlib==3.2.2 166 | # via requests-oauthlib 167 | packaging==24.0 168 | # via 169 | # msal-extensions 170 | # pytest 171 | pandas==2.2.1 172 | paramiko==3.4.0 173 | # via textual-universal-directorytree 174 | pillow==10.2.0 175 | # via rich-pixels 176 | pluggy==1.4.0 177 | # via pytest 178 | portalocker==2.8.2 179 | # via msal-extensions 180 | protobuf==4.25.3 181 | # via 182 | # google-api-core 183 | # googleapis-common-protos 184 | pyarrow==15.0.2 185 | pyasn1==0.5.1 186 | # via 187 | # pyasn1-modules 188 | # rsa 189 | pyasn1-modules==0.3.0 190 | # via google-auth 191 | pycparser==2.21 192 | # via cffi 193 | pygments==2.17.2 194 | # via rich 195 | pyjwt==2.8.0 196 | # via msal 197 | pymupdf==1.23.26 198 | pymupdfb==1.23.22 199 | # via pymupdf 200 | pynacl==1.5.0 201 | # via paramiko 202 | pyperclip==1.8.2 203 | pytest==8.1.1 204 | # via 205 | # pytest-asyncio 206 | # pytest-cov 207 | # pytest-textual-snapshot 208 | # pytest-vcr 209 | # syrupy 210 | pytest-asyncio==0.23.6 211 | pytest-cov==4.1.0 212 | pytest-textual-snapshot==0.4.0 213 | pytest-vcr==1.0.2 214 | python-dateutil==2.9.0.post0 215 | # via 216 | # botocore 217 | # pandas 218 | pytz==2024.1 219 | # via pandas 220 | pyyaml==6.0.1 221 | # via vcrpy 222 | requests==2.31.0 223 | # via 224 | # azure-core 225 | # azure-datalake-store 226 | # gcsfs 227 | # google-api-core 228 | # google-cloud-storage 229 | # msal 230 | # requests-oauthlib 231 | # textual-universal-directorytree 232 | requests-oauthlib==1.4.0 233 | # via google-auth-oauthlib 234 | rich==13.7.1 235 | # via 236 | # pytest-textual-snapshot 237 | # rich-click 238 | # rich-pixels 239 | # textual 240 | rich-click==1.7.4 241 | rich-pixels==2.2.0 242 | rsa==4.9 243 | # via google-auth 244 | s3fs==2024.3.1 245 | # via textual-universal-directorytree 246 | six==1.16.0 247 | # via 248 | # azure-core 249 | # isodate 250 | # python-dateutil 251 | syrupy==4.6.1 252 | # via pytest-textual-snapshot 253 | textual==0.53.1 254 | # via 255 | # pytest-textual-snapshot 256 | # textual-dev 257 | # textual-universal-directorytree 258 | textual-dev==1.4.0 259 | textual-universal-directorytree==1.5.0 260 | typing-extensions==4.10.0 261 | # via 262 | # azure-core 263 | # azure-storage-blob 264 | # rich-click 265 | # textual 266 | # textual-dev 267 | tzdata==2024.1 268 | # via pandas 269 | uc-micro-py==1.0.3 270 | # via linkify-it-py 271 | universal-pathlib==0.2.2 272 | # via textual-universal-directorytree 273 | urllib3==2.0.7 274 | # via 275 | # botocore 276 | # requests 277 | vcrpy==6.0.1 278 | # via pytest-vcr 279 | wrapt==1.16.0 280 | # via 281 | # aiobotocore 282 | # vcrpy 283 | yarl==1.9.4 284 | # via 285 | # aiohttp 286 | # vcrpy 287 | -------------------------------------------------------------------------------- /requirements/requirements-all.py3.8.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.8 3 | # 4 | # - pytest 5 | # - pytest-cov 6 | # - pytest-vcr~=1.0.2 7 | # - textual-dev~=1.4.0 8 | # - pytest-textual-snapshot 9 | # - pytest-asyncio 10 | # - art~=6.1 11 | # - click~=8.1.7 12 | # - pandas<3,>2 13 | # - pillow>=10.2.0 14 | # - pymupdf~=1.23.26 15 | # - pyperclip~=1.8.2 16 | # - rich-click~=1.7.4 17 | # - rich-pixels~=2.2.0 18 | # - rich~=13.7.1 19 | # - textual-universal-directorytree~=1.5.0 20 | # - textual==0.53.1 21 | # - universal-pathlib~=0.2.2 22 | # - pyarrow~=15.0.2 23 | # - textual-universal-directorytree[remote]~=1.5.0 24 | # 25 | 26 | adlfs==2024.2.0 27 | # via textual-universal-directorytree 28 | aiobotocore==2.12.1 29 | # via s3fs 30 | aiohttp==3.9.3 31 | # via 32 | # adlfs 33 | # aiobotocore 34 | # gcsfs 35 | # s3fs 36 | # textual-dev 37 | # textual-universal-directorytree 38 | aioitertools==0.11.0 39 | # via aiobotocore 40 | aiosignal==1.3.1 41 | # via aiohttp 42 | art==6.1 43 | async-timeout==4.0.3 44 | # via aiohttp 45 | attrs==23.2.0 46 | # via aiohttp 47 | azure-core==1.30.1 48 | # via 49 | # adlfs 50 | # azure-identity 51 | # azure-storage-blob 52 | azure-datalake-store==0.0.53 53 | # via adlfs 54 | azure-identity==1.15.0 55 | # via adlfs 56 | azure-storage-blob==12.19.1 57 | # via adlfs 58 | bcrypt==4.1.2 59 | # via paramiko 60 | botocore==1.34.51 61 | # via aiobotocore 62 | cachetools==5.3.3 63 | # via google-auth 64 | certifi==2024.2.2 65 | # via requests 66 | cffi==1.16.0 67 | # via 68 | # azure-datalake-store 69 | # cryptography 70 | # pynacl 71 | charset-normalizer==3.3.2 72 | # via requests 73 | click==8.1.7 74 | # via 75 | # rich-click 76 | # textual-dev 77 | coverage==7.4.4 78 | # via pytest-cov 79 | cryptography==42.0.5 80 | # via 81 | # azure-identity 82 | # azure-storage-blob 83 | # msal 84 | # paramiko 85 | # pyjwt 86 | decorator==5.1.1 87 | # via gcsfs 88 | exceptiongroup==1.2.0 89 | # via pytest 90 | frozenlist==1.4.1 91 | # via 92 | # aiohttp 93 | # aiosignal 94 | fsspec==2024.3.1 95 | # via 96 | # adlfs 97 | # gcsfs 98 | # s3fs 99 | # universal-pathlib 100 | gcsfs==2024.3.1 101 | # via textual-universal-directorytree 102 | google-api-core==2.17.1 103 | # via 104 | # google-cloud-core 105 | # google-cloud-storage 106 | google-auth==2.28.2 107 | # via 108 | # gcsfs 109 | # google-api-core 110 | # google-auth-oauthlib 111 | # google-cloud-core 112 | # google-cloud-storage 113 | google-auth-oauthlib==1.2.0 114 | # via gcsfs 115 | google-cloud-core==2.4.1 116 | # via google-cloud-storage 117 | google-cloud-storage==2.16.0 118 | # via gcsfs 119 | google-crc32c==1.5.0 120 | # via 121 | # google-cloud-storage 122 | # google-resumable-media 123 | google-resumable-media==2.7.0 124 | # via google-cloud-storage 125 | googleapis-common-protos==1.63.0 126 | # via google-api-core 127 | idna==3.6 128 | # via 129 | # requests 130 | # yarl 131 | iniconfig==2.0.0 132 | # via pytest 133 | isodate==0.6.1 134 | # via azure-storage-blob 135 | jinja2==3.1.3 136 | # via pytest-textual-snapshot 137 | jmespath==1.0.1 138 | # via botocore 139 | linkify-it-py==2.0.3 140 | # via markdown-it-py 141 | markdown-it-py==3.0.0 142 | # via 143 | # mdit-py-plugins 144 | # rich 145 | # textual 146 | markupsafe==2.1.5 147 | # via jinja2 148 | mdit-py-plugins==0.4.0 149 | # via markdown-it-py 150 | mdurl==0.1.2 151 | # via markdown-it-py 152 | msal==1.28.0 153 | # via 154 | # azure-datalake-store 155 | # azure-identity 156 | # msal-extensions 157 | msal-extensions==1.1.0 158 | # via azure-identity 159 | msgpack==1.0.8 160 | # via textual-dev 161 | multidict==6.0.5 162 | # via 163 | # aiohttp 164 | # yarl 165 | numpy==1.24.4 166 | # via 167 | # pandas 168 | # pyarrow 169 | oauthlib==3.2.2 170 | # via requests-oauthlib 171 | packaging==24.0 172 | # via 173 | # msal-extensions 174 | # pytest 175 | pandas==2.0.3 176 | paramiko==3.4.0 177 | # via textual-universal-directorytree 178 | pillow==10.2.0 179 | # via rich-pixels 180 | pluggy==1.4.0 181 | # via pytest 182 | portalocker==2.8.2 183 | # via msal-extensions 184 | protobuf==4.25.3 185 | # via 186 | # google-api-core 187 | # googleapis-common-protos 188 | pyarrow==15.0.2 189 | pyasn1==0.5.1 190 | # via 191 | # pyasn1-modules 192 | # rsa 193 | pyasn1-modules==0.3.0 194 | # via google-auth 195 | pycparser==2.21 196 | # via cffi 197 | pygments==2.17.2 198 | # via rich 199 | pyjwt==2.8.0 200 | # via msal 201 | pymupdf==1.23.26 202 | pymupdfb==1.23.22 203 | # via pymupdf 204 | pynacl==1.5.0 205 | # via paramiko 206 | pyperclip==1.8.2 207 | pytest==8.1.1 208 | # via 209 | # pytest-asyncio 210 | # pytest-cov 211 | # pytest-textual-snapshot 212 | # pytest-vcr 213 | # syrupy 214 | pytest-asyncio==0.23.6 215 | pytest-cov==4.1.0 216 | pytest-textual-snapshot==0.4.0 217 | pytest-vcr==1.0.2 218 | python-dateutil==2.9.0.post0 219 | # via 220 | # botocore 221 | # pandas 222 | pytz==2024.1 223 | # via pandas 224 | pyyaml==6.0.1 225 | # via vcrpy 226 | requests==2.31.0 227 | # via 228 | # azure-core 229 | # azure-datalake-store 230 | # gcsfs 231 | # google-api-core 232 | # google-cloud-storage 233 | # msal 234 | # requests-oauthlib 235 | # textual-universal-directorytree 236 | requests-oauthlib==1.4.0 237 | # via google-auth-oauthlib 238 | rich==13.7.1 239 | # via 240 | # pytest-textual-snapshot 241 | # rich-click 242 | # rich-pixels 243 | # textual 244 | rich-click==1.7.4 245 | rich-pixels==2.2.0 246 | rsa==4.9 247 | # via google-auth 248 | s3fs==2024.3.1 249 | # via textual-universal-directorytree 250 | six==1.16.0 251 | # via 252 | # azure-core 253 | # isodate 254 | # python-dateutil 255 | syrupy==4.6.1 256 | # via pytest-textual-snapshot 257 | textual==0.53.1 258 | # via 259 | # pytest-textual-snapshot 260 | # textual-dev 261 | # textual-universal-directorytree 262 | textual-dev==1.4.0 263 | textual-universal-directorytree==1.5.0 264 | tomli==2.0.1 265 | # via 266 | # coverage 267 | # pytest 268 | typing-extensions==4.10.0 269 | # via 270 | # aioitertools 271 | # azure-core 272 | # azure-storage-blob 273 | # rich 274 | # rich-click 275 | # textual 276 | # textual-dev 277 | tzdata==2024.1 278 | # via pandas 279 | uc-micro-py==1.0.3 280 | # via linkify-it-py 281 | universal-pathlib==0.2.2 282 | # via textual-universal-directorytree 283 | urllib3==1.26.18 284 | # via 285 | # botocore 286 | # requests 287 | # vcrpy 288 | vcrpy==6.0.1 289 | # via pytest-vcr 290 | wrapt==1.16.0 291 | # via 292 | # aiobotocore 293 | # vcrpy 294 | yarl==1.9.4 295 | # via 296 | # aiohttp 297 | # vcrpy 298 | -------------------------------------------------------------------------------- /requirements/requirements-all.py3.9.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.9 3 | # 4 | # - pytest 5 | # - pytest-cov 6 | # - pytest-vcr~=1.0.2 7 | # - textual-dev~=1.4.0 8 | # - pytest-textual-snapshot 9 | # - pytest-asyncio 10 | # - art~=6.1 11 | # - click~=8.1.7 12 | # - pandas<3,>2 13 | # - pillow>=10.2.0 14 | # - pymupdf~=1.23.26 15 | # - pyperclip~=1.8.2 16 | # - rich-click~=1.7.4 17 | # - rich-pixels~=2.2.0 18 | # - rich~=13.7.1 19 | # - textual-universal-directorytree~=1.5.0 20 | # - textual==0.53.1 21 | # - universal-pathlib~=0.2.2 22 | # - pyarrow~=15.0.2 23 | # - textual-universal-directorytree[remote]~=1.5.0 24 | # 25 | 26 | adlfs==2024.2.0 27 | # via textual-universal-directorytree 28 | aiobotocore==2.12.1 29 | # via s3fs 30 | aiohttp==3.9.3 31 | # via 32 | # adlfs 33 | # aiobotocore 34 | # gcsfs 35 | # s3fs 36 | # textual-dev 37 | # textual-universal-directorytree 38 | aioitertools==0.11.0 39 | # via aiobotocore 40 | aiosignal==1.3.1 41 | # via aiohttp 42 | art==6.1 43 | async-timeout==4.0.3 44 | # via aiohttp 45 | attrs==23.2.0 46 | # via aiohttp 47 | azure-core==1.30.1 48 | # via 49 | # adlfs 50 | # azure-identity 51 | # azure-storage-blob 52 | azure-datalake-store==0.0.53 53 | # via adlfs 54 | azure-identity==1.15.0 55 | # via adlfs 56 | azure-storage-blob==12.19.1 57 | # via adlfs 58 | bcrypt==4.1.2 59 | # via paramiko 60 | botocore==1.34.51 61 | # via aiobotocore 62 | cachetools==5.3.3 63 | # via google-auth 64 | certifi==2024.2.2 65 | # via requests 66 | cffi==1.16.0 67 | # via 68 | # azure-datalake-store 69 | # cryptography 70 | # pynacl 71 | charset-normalizer==3.3.2 72 | # via requests 73 | click==8.1.7 74 | # via 75 | # rich-click 76 | # textual-dev 77 | coverage==7.4.4 78 | # via pytest-cov 79 | cryptography==42.0.5 80 | # via 81 | # azure-identity 82 | # azure-storage-blob 83 | # msal 84 | # paramiko 85 | # pyjwt 86 | decorator==5.1.1 87 | # via gcsfs 88 | exceptiongroup==1.2.0 89 | # via pytest 90 | frozenlist==1.4.1 91 | # via 92 | # aiohttp 93 | # aiosignal 94 | fsspec==2024.3.1 95 | # via 96 | # adlfs 97 | # gcsfs 98 | # s3fs 99 | # universal-pathlib 100 | gcsfs==2024.3.1 101 | # via textual-universal-directorytree 102 | google-api-core==2.17.1 103 | # via 104 | # google-cloud-core 105 | # google-cloud-storage 106 | google-auth==2.28.2 107 | # via 108 | # gcsfs 109 | # google-api-core 110 | # google-auth-oauthlib 111 | # google-cloud-core 112 | # google-cloud-storage 113 | google-auth-oauthlib==1.2.0 114 | # via gcsfs 115 | google-cloud-core==2.4.1 116 | # via google-cloud-storage 117 | google-cloud-storage==2.16.0 118 | # via gcsfs 119 | google-crc32c==1.5.0 120 | # via 121 | # google-cloud-storage 122 | # google-resumable-media 123 | google-resumable-media==2.7.0 124 | # via google-cloud-storage 125 | googleapis-common-protos==1.63.0 126 | # via google-api-core 127 | idna==3.6 128 | # via 129 | # requests 130 | # yarl 131 | iniconfig==2.0.0 132 | # via pytest 133 | isodate==0.6.1 134 | # via azure-storage-blob 135 | jinja2==3.1.3 136 | # via pytest-textual-snapshot 137 | jmespath==1.0.1 138 | # via botocore 139 | linkify-it-py==2.0.3 140 | # via markdown-it-py 141 | markdown-it-py==3.0.0 142 | # via 143 | # mdit-py-plugins 144 | # rich 145 | # textual 146 | markupsafe==2.1.5 147 | # via jinja2 148 | mdit-py-plugins==0.4.0 149 | # via markdown-it-py 150 | mdurl==0.1.2 151 | # via markdown-it-py 152 | msal==1.28.0 153 | # via 154 | # azure-datalake-store 155 | # azure-identity 156 | # msal-extensions 157 | msal-extensions==1.1.0 158 | # via azure-identity 159 | msgpack==1.0.8 160 | # via textual-dev 161 | multidict==6.0.5 162 | # via 163 | # aiohttp 164 | # yarl 165 | numpy==1.26.4 166 | # via 167 | # pandas 168 | # pyarrow 169 | oauthlib==3.2.2 170 | # via requests-oauthlib 171 | packaging==24.0 172 | # via 173 | # msal-extensions 174 | # pytest 175 | pandas==2.2.1 176 | paramiko==3.4.0 177 | # via textual-universal-directorytree 178 | pillow==10.2.0 179 | # via rich-pixels 180 | pluggy==1.4.0 181 | # via pytest 182 | portalocker==2.8.2 183 | # via msal-extensions 184 | protobuf==4.25.3 185 | # via 186 | # google-api-core 187 | # googleapis-common-protos 188 | pyarrow==15.0.2 189 | pyasn1==0.5.1 190 | # via 191 | # pyasn1-modules 192 | # rsa 193 | pyasn1-modules==0.3.0 194 | # via google-auth 195 | pycparser==2.21 196 | # via cffi 197 | pygments==2.17.2 198 | # via rich 199 | pyjwt==2.8.0 200 | # via msal 201 | pymupdf==1.23.26 202 | pymupdfb==1.23.22 203 | # via pymupdf 204 | pynacl==1.5.0 205 | # via paramiko 206 | pyperclip==1.8.2 207 | pytest==8.1.1 208 | # via 209 | # pytest-asyncio 210 | # pytest-cov 211 | # pytest-textual-snapshot 212 | # pytest-vcr 213 | # syrupy 214 | pytest-asyncio==0.23.6 215 | pytest-cov==4.1.0 216 | pytest-textual-snapshot==0.4.0 217 | pytest-vcr==1.0.2 218 | python-dateutil==2.9.0.post0 219 | # via 220 | # botocore 221 | # pandas 222 | pytz==2024.1 223 | # via pandas 224 | pyyaml==6.0.1 225 | # via vcrpy 226 | requests==2.31.0 227 | # via 228 | # azure-core 229 | # azure-datalake-store 230 | # gcsfs 231 | # google-api-core 232 | # google-cloud-storage 233 | # msal 234 | # requests-oauthlib 235 | # textual-universal-directorytree 236 | requests-oauthlib==1.4.0 237 | # via google-auth-oauthlib 238 | rich==13.7.1 239 | # via 240 | # pytest-textual-snapshot 241 | # rich-click 242 | # rich-pixels 243 | # textual 244 | rich-click==1.7.4 245 | rich-pixels==2.2.0 246 | rsa==4.9 247 | # via google-auth 248 | s3fs==2024.3.1 249 | # via textual-universal-directorytree 250 | six==1.16.0 251 | # via 252 | # azure-core 253 | # isodate 254 | # python-dateutil 255 | syrupy==4.6.1 256 | # via pytest-textual-snapshot 257 | textual==0.53.1 258 | # via 259 | # pytest-textual-snapshot 260 | # textual-dev 261 | # textual-universal-directorytree 262 | textual-dev==1.4.0 263 | textual-universal-directorytree==1.5.0 264 | tomli==2.0.1 265 | # via 266 | # coverage 267 | # pytest 268 | typing-extensions==4.10.0 269 | # via 270 | # aioitertools 271 | # azure-core 272 | # azure-storage-blob 273 | # rich-click 274 | # textual 275 | # textual-dev 276 | tzdata==2024.1 277 | # via pandas 278 | uc-micro-py==1.0.3 279 | # via linkify-it-py 280 | universal-pathlib==0.2.2 281 | # via textual-universal-directorytree 282 | urllib3==1.26.18 283 | # via 284 | # botocore 285 | # requests 286 | # vcrpy 287 | vcrpy==6.0.1 288 | # via pytest-vcr 289 | wrapt==1.16.0 290 | # via 291 | # aiobotocore 292 | # vcrpy 293 | yarl==1.9.4 294 | # via 295 | # aiohttp 296 | # vcrpy 297 | -------------------------------------------------------------------------------- /requirements/requirements-docs.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.11 3 | # 4 | # [constraints] requirements.txt (SHA256: bc0fefdf4f70fa5cc8cff0ac053788468d6f87228ad013826bf346b8ea2f7053) 5 | # 6 | # - markdown-callouts 7 | # - markdown-exec 8 | # - mkdocs 9 | # - mkdocs-autorefs 10 | # - mkdocs-click 11 | # - mkdocs-gen-files 12 | # - mkdocs-literate-nav 13 | # - mkdocs-material 14 | # - mkdocs-section-index 15 | # - mkdocstrings 16 | # - mkdocstrings-python 17 | # - pymdown-extensions 18 | # - art~=6.1 19 | # - click~=8.1.7 20 | # - pandas<3,>2 21 | # - pillow>=10.2.0 22 | # - pymupdf~=1.23.26 23 | # - pyperclip~=1.8.2 24 | # - rich-click~=1.7.4 25 | # - rich-pixels~=2.2.0 26 | # - rich~=13.7.1 27 | # - textual-universal-directorytree~=1.5.0 28 | # - textual==0.53.1 29 | # - universal-pathlib~=0.2.2 30 | # 31 | 32 | art==6.1 33 | babel==2.14.0 34 | # via mkdocs-material 35 | certifi==2024.2.2 36 | # via requests 37 | charset-normalizer==3.3.2 38 | # via requests 39 | click==8.1.7 40 | # via 41 | # mkdocs 42 | # mkdocs-click 43 | # mkdocstrings 44 | # rich-click 45 | colorama==0.4.6 46 | # via 47 | # griffe 48 | # mkdocs-material 49 | fsspec==2024.3.1 50 | # via universal-pathlib 51 | ghp-import==2.1.0 52 | # via mkdocs 53 | griffe==0.42.1 54 | # via mkdocstrings-python 55 | idna==3.6 56 | # via requests 57 | jinja2==3.1.3 58 | # via 59 | # mkdocs 60 | # mkdocs-material 61 | # mkdocstrings 62 | linkify-it-py==2.0.3 63 | # via markdown-it-py 64 | markdown==3.5.2 65 | # via 66 | # markdown-callouts 67 | # mkdocs 68 | # mkdocs-autorefs 69 | # mkdocs-click 70 | # mkdocs-material 71 | # mkdocstrings 72 | # mkdocstrings-python 73 | # pymdown-extensions 74 | markdown-callouts==0.4.0 75 | markdown-exec==1.8.0 76 | markdown-it-py==3.0.0 77 | # via 78 | # mdit-py-plugins 79 | # rich 80 | # textual 81 | markupsafe==2.1.5 82 | # via 83 | # jinja2 84 | # mkdocs 85 | # mkdocs-autorefs 86 | # mkdocstrings 87 | mdit-py-plugins==0.4.0 88 | # via markdown-it-py 89 | mdurl==0.1.2 90 | # via markdown-it-py 91 | mergedeep==1.3.4 92 | # via mkdocs 93 | mkdocs==1.5.3 94 | # via 95 | # mkdocs-autorefs 96 | # mkdocs-gen-files 97 | # mkdocs-literate-nav 98 | # mkdocs-material 99 | # mkdocs-section-index 100 | # mkdocstrings 101 | mkdocs-autorefs==1.0.1 102 | # via mkdocstrings 103 | mkdocs-click==0.8.1 104 | mkdocs-gen-files==0.5.0 105 | mkdocs-literate-nav==0.6.1 106 | mkdocs-material==9.5.14 107 | mkdocs-material-extensions==1.3.1 108 | # via mkdocs-material 109 | mkdocs-section-index==0.3.8 110 | mkdocstrings==0.24.1 111 | # via mkdocstrings-python 112 | mkdocstrings-python==1.9.0 113 | numpy==1.26.4 114 | # via pandas 115 | packaging==24.0 116 | # via mkdocs 117 | paginate==0.5.6 118 | # via mkdocs-material 119 | pandas==2.2.1 120 | pathspec==0.12.1 121 | # via mkdocs 122 | pillow==10.2.0 123 | # via rich-pixels 124 | platformdirs==4.2.0 125 | # via 126 | # mkdocs 127 | # mkdocstrings 128 | pygments==2.17.2 129 | # via 130 | # mkdocs-material 131 | # rich 132 | pymdown-extensions==10.7.1 133 | # via 134 | # markdown-exec 135 | # mkdocs-material 136 | # mkdocstrings 137 | pymupdf==1.23.26 138 | pymupdfb==1.23.22 139 | # via pymupdf 140 | pyperclip==1.8.2 141 | python-dateutil==2.9.0.post0 142 | # via 143 | # ghp-import 144 | # pandas 145 | pytz==2024.1 146 | # via pandas 147 | pyyaml==6.0.1 148 | # via 149 | # mkdocs 150 | # pymdown-extensions 151 | # pyyaml-env-tag 152 | pyyaml-env-tag==0.1 153 | # via mkdocs 154 | regex==2023.12.25 155 | # via mkdocs-material 156 | requests==2.31.0 157 | # via mkdocs-material 158 | rich==13.7.1 159 | # via 160 | # rich-click 161 | # rich-pixels 162 | # textual 163 | rich-click==1.7.4 164 | rich-pixels==2.2.0 165 | six==1.16.0 166 | # via python-dateutil 167 | textual==0.53.1 168 | # via textual-universal-directorytree 169 | textual-universal-directorytree==1.5.0 170 | typing-extensions==4.10.0 171 | # via 172 | # rich-click 173 | # textual 174 | tzdata==2024.1 175 | # via pandas 176 | uc-micro-py==1.0.3 177 | # via linkify-it-py 178 | universal-pathlib==0.2.2 179 | # via textual-universal-directorytree 180 | urllib3==2.0.7 181 | # via requests 182 | watchdog==4.0.0 183 | # via mkdocs 184 | -------------------------------------------------------------------------------- /requirements/requirements-lint.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.11 3 | # 4 | # - mypy>=1.9.0 5 | # - ruff~=0.1.7 6 | # 7 | 8 | mypy==1.9.0 9 | # via hatch.envs.lint 10 | mypy-extensions==1.0.0 11 | # via mypy 12 | ruff==0.1.15 13 | # via hatch.envs.lint 14 | typing-extensions==4.10.0 15 | # via mypy 16 | -------------------------------------------------------------------------------- /requirements/requirements-test.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by hatch-pip-compile with Python 3.11 3 | # 4 | # [constraints] requirements.txt (SHA256: bc0fefdf4f70fa5cc8cff0ac053788468d6f87228ad013826bf346b8ea2f7053) 5 | # 6 | # - pytest 7 | # - pytest-cov 8 | # - pytest-vcr~=1.0.2 9 | # - textual-dev~=1.4.0 10 | # - pytest-textual-snapshot 11 | # - pytest-asyncio 12 | # - art~=6.1 13 | # - click~=8.1.7 14 | # - pandas<3,>2 15 | # - pillow>=10.2.0 16 | # - pymupdf~=1.23.26 17 | # - pyperclip~=1.8.2 18 | # - rich-click~=1.7.4 19 | # - rich-pixels~=2.2.0 20 | # - rich~=13.7.1 21 | # - textual-universal-directorytree~=1.5.0 22 | # - textual==0.53.1 23 | # - universal-pathlib~=0.2.2 24 | # - pyarrow~=15.0.2 25 | # - textual-universal-directorytree[remote]~=1.5.0 26 | # 27 | 28 | adlfs==2024.2.0 29 | # via textual-universal-directorytree 30 | aiobotocore==2.12.1 31 | # via s3fs 32 | aiohttp==3.9.3 33 | # via 34 | # adlfs 35 | # aiobotocore 36 | # gcsfs 37 | # s3fs 38 | # textual-dev 39 | # textual-universal-directorytree 40 | aioitertools==0.11.0 41 | # via aiobotocore 42 | aiosignal==1.3.1 43 | # via aiohttp 44 | art==6.1 45 | attrs==23.2.0 46 | # via aiohttp 47 | azure-core==1.30.1 48 | # via 49 | # adlfs 50 | # azure-identity 51 | # azure-storage-blob 52 | azure-datalake-store==0.0.53 53 | # via adlfs 54 | azure-identity==1.15.0 55 | # via adlfs 56 | azure-storage-blob==12.19.1 57 | # via adlfs 58 | bcrypt==4.1.2 59 | # via paramiko 60 | botocore==1.34.51 61 | # via aiobotocore 62 | cachetools==5.3.3 63 | # via google-auth 64 | certifi==2024.2.2 65 | # via requests 66 | cffi==1.16.0 67 | # via 68 | # azure-datalake-store 69 | # cryptography 70 | # pynacl 71 | charset-normalizer==3.3.2 72 | # via requests 73 | click==8.1.7 74 | # via 75 | # rich-click 76 | # textual-dev 77 | coverage==7.4.4 78 | # via pytest-cov 79 | cryptography==42.0.5 80 | # via 81 | # azure-identity 82 | # azure-storage-blob 83 | # msal 84 | # paramiko 85 | # pyjwt 86 | decorator==5.1.1 87 | # via gcsfs 88 | frozenlist==1.4.1 89 | # via 90 | # aiohttp 91 | # aiosignal 92 | fsspec==2024.3.1 93 | # via 94 | # adlfs 95 | # gcsfs 96 | # s3fs 97 | # universal-pathlib 98 | gcsfs==2024.3.1 99 | # via textual-universal-directorytree 100 | google-api-core==2.17.1 101 | # via 102 | # google-cloud-core 103 | # google-cloud-storage 104 | google-auth==2.28.2 105 | # via 106 | # gcsfs 107 | # google-api-core 108 | # google-auth-oauthlib 109 | # google-cloud-core 110 | # google-cloud-storage 111 | google-auth-oauthlib==1.2.0 112 | # via gcsfs 113 | google-cloud-core==2.4.1 114 | # via google-cloud-storage 115 | google-cloud-storage==2.16.0 116 | # via gcsfs 117 | google-crc32c==1.5.0 118 | # via 119 | # google-cloud-storage 120 | # google-resumable-media 121 | google-resumable-media==2.7.0 122 | # via google-cloud-storage 123 | googleapis-common-protos==1.63.0 124 | # via google-api-core 125 | idna==3.6 126 | # via 127 | # requests 128 | # yarl 129 | iniconfig==2.0.0 130 | # via pytest 131 | isodate==0.6.1 132 | # via azure-storage-blob 133 | jinja2==3.1.3 134 | # via pytest-textual-snapshot 135 | jmespath==1.0.1 136 | # via botocore 137 | linkify-it-py==2.0.3 138 | # via markdown-it-py 139 | markdown-it-py==3.0.0 140 | # via 141 | # mdit-py-plugins 142 | # rich 143 | # textual 144 | markupsafe==2.1.5 145 | # via jinja2 146 | mdit-py-plugins==0.4.0 147 | # via markdown-it-py 148 | mdurl==0.1.2 149 | # via markdown-it-py 150 | msal==1.28.0 151 | # via 152 | # azure-datalake-store 153 | # azure-identity 154 | # msal-extensions 155 | msal-extensions==1.1.0 156 | # via azure-identity 157 | msgpack==1.0.8 158 | # via textual-dev 159 | multidict==6.0.5 160 | # via 161 | # aiohttp 162 | # yarl 163 | numpy==1.26.4 164 | # via 165 | # pandas 166 | # pyarrow 167 | oauthlib==3.2.2 168 | # via requests-oauthlib 169 | packaging==24.0 170 | # via 171 | # msal-extensions 172 | # pytest 173 | pandas==2.2.1 174 | paramiko==3.4.0 175 | # via textual-universal-directorytree 176 | pillow==10.2.0 177 | # via rich-pixels 178 | pluggy==1.4.0 179 | # via pytest 180 | portalocker==2.8.2 181 | # via msal-extensions 182 | protobuf==4.25.3 183 | # via 184 | # google-api-core 185 | # googleapis-common-protos 186 | pyarrow==15.0.2 187 | pyasn1==0.5.1 188 | # via 189 | # pyasn1-modules 190 | # rsa 191 | pyasn1-modules==0.3.0 192 | # via google-auth 193 | pycparser==2.21 194 | # via cffi 195 | pygments==2.17.2 196 | # via rich 197 | pyjwt==2.8.0 198 | # via msal 199 | pymupdf==1.23.26 200 | pymupdfb==1.23.22 201 | # via pymupdf 202 | pynacl==1.5.0 203 | # via paramiko 204 | pyperclip==1.8.2 205 | pytest==8.1.1 206 | # via 207 | # pytest-asyncio 208 | # pytest-cov 209 | # pytest-textual-snapshot 210 | # pytest-vcr 211 | # syrupy 212 | pytest-asyncio==0.23.6 213 | pytest-cov==4.1.0 214 | pytest-textual-snapshot==0.4.0 215 | pytest-vcr==1.0.2 216 | python-dateutil==2.9.0.post0 217 | # via 218 | # botocore 219 | # pandas 220 | pytz==2024.1 221 | # via pandas 222 | pyyaml==6.0.1 223 | # via vcrpy 224 | requests==2.31.0 225 | # via 226 | # azure-core 227 | # azure-datalake-store 228 | # gcsfs 229 | # google-api-core 230 | # google-cloud-storage 231 | # msal 232 | # requests-oauthlib 233 | # textual-universal-directorytree 234 | requests-oauthlib==1.4.0 235 | # via google-auth-oauthlib 236 | rich==13.7.1 237 | # via 238 | # pytest-textual-snapshot 239 | # rich-click 240 | # rich-pixels 241 | # textual 242 | rich-click==1.7.4 243 | rich-pixels==2.2.0 244 | rsa==4.9 245 | # via google-auth 246 | s3fs==2024.3.1 247 | # via textual-universal-directorytree 248 | six==1.16.0 249 | # via 250 | # azure-core 251 | # isodate 252 | # python-dateutil 253 | syrupy==4.6.1 254 | # via pytest-textual-snapshot 255 | textual==0.53.1 256 | # via 257 | # pytest-textual-snapshot 258 | # textual-dev 259 | # textual-universal-directorytree 260 | textual-dev==1.4.0 261 | textual-universal-directorytree==1.5.0 262 | typing-extensions==4.10.0 263 | # via 264 | # azure-core 265 | # azure-storage-blob 266 | # rich-click 267 | # textual 268 | # textual-dev 269 | tzdata==2024.1 270 | # via pandas 271 | uc-micro-py==1.0.3 272 | # via linkify-it-py 273 | universal-pathlib==0.2.2 274 | # via textual-universal-directorytree 275 | urllib3==2.0.7 276 | # via 277 | # botocore 278 | # requests 279 | vcrpy==6.0.1 280 | # via pytest-vcr 281 | wrapt==1.16.0 282 | # via 283 | # aiobotocore 284 | # vcrpy 285 | yarl==1.9.4 286 | # via 287 | # aiohttp 288 | # vcrpy 289 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juftin/browsr/4d4eecb02e33654313b7d7e688d2be6c49688211/tests/__init__.py -------------------------------------------------------------------------------- /tests/cassettes/test_github_screenshot.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - "*/*" 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | authorization: 14 | - XXXXXXXXXX 15 | method: GET 16 | uri: https://api.github.com/repos/juftin/browsr/git/trees/v1.6.0 17 | response: 18 | body: 19 | string: '{"sha":"b80ea1937572041e2ed884f3d17919a7324b080b","url":"https://api.github.com/repos/juftin/browsr/git/trees/b80ea1937572041e2ed884f3d17919a7324b080b","tree":[{"path":".github","mode":"040000","type":"tree","sha":"a901aed96b47ecefdef6bf3530856065a76ff9f8","url":"https://api.github.com/repos/juftin/browsr/git/trees/a901aed96b47ecefdef6bf3530856065a76ff9f8"},{"path":".gitignore","mode":"100644","type":"blob","sha":"7e0d4d450a35a973ed5d4d1eeb749861ffb68ecf","size":2804,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/7e0d4d450a35a973ed5d4d1eeb749861ffb68ecf"},{"path":".pre-commit-config.yaml","mode":"100644","type":"blob","sha":"a9d8704a8c8511e6a430032ec2615c3dfeb7fb84","size":1379,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/a9d8704a8c8511e6a430032ec2615c3dfeb7fb84"},{"path":".releaserc.js","mode":"100644","type":"blob","sha":"71ebbd3ec251758bf9e3ac6b1c246808af9d289a","size":1574,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/71ebbd3ec251758bf9e3ac6b1c246808af9d289a"},{"path":"LICENSE","mode":"100644","type":"blob","sha":"3f1116db9ccaba831afbd47542a8dbef1ceb2189","size":1109,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/3f1116db9ccaba831afbd47542a8dbef1ceb2189"},{"path":"README.md","mode":"100644","type":"blob","sha":"1b7227281e1ac90b906c05a82ce968b6d5e3114c","size":2728,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/1b7227281e1ac90b906c05a82ce968b6d5e3114c"},{"path":"browsr","mode":"040000","type":"tree","sha":"dd4f0ba68388bc8382120b638a14497bafd3945b","url":"https://api.github.com/repos/juftin/browsr/git/trees/dd4f0ba68388bc8382120b638a14497bafd3945b"},{"path":"docs","mode":"040000","type":"tree","sha":"de31dc85a413f8f2cdd79130276faac5e93efcc9","url":"https://api.github.com/repos/juftin/browsr/git/trees/de31dc85a413f8f2cdd79130276faac5e93efcc9"},{"path":"mkdocs.yaml","mode":"100644","type":"blob","sha":"d5151fff1451011cdac254c3745e15e78af77064","size":2183,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/d5151fff1451011cdac254c3745e15e78af77064"},{"path":"pyproject.toml","mode":"100644","type":"blob","sha":"98be6018858bf4ccf1dafa7509d388ea08537ed7","size":4448,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/98be6018858bf4ccf1dafa7509d388ea08537ed7"},{"path":"requirements","mode":"040000","type":"tree","sha":"3b044dfacdcb0bd4b3d9b032363d135c5894e410","url":"https://api.github.com/repos/juftin/browsr/git/trees/3b044dfacdcb0bd4b3d9b032363d135c5894e410"},{"path":"tests","mode":"040000","type":"tree","sha":"c25588f3e19c20ee684b21551db7635b155b3cf9","url":"https://api.github.com/repos/juftin/browsr/git/trees/c25588f3e19c20ee684b21551db7635b155b3cf9"}],"truncated":false}' 20 | headers: 21 | Access-Control-Allow-Origin: 22 | - "*" 23 | Access-Control-Expose-Headers: 24 | - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, 25 | X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, 26 | X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, 27 | X-GitHub-Request-Id, Deprecation, Sunset 28 | Cache-Control: 29 | - private, max-age=60, s-maxage=60 30 | 31 | Content-Security-Policy: 32 | - default-src 'none' 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Fri, 22 Dec 2023 02:40:00 GMT 37 | ETag: 38 | - W/"bbfd14c82ededc5d6619f1ca1d1aa8852c6efbee36a352a067516ea4c77807ca" 39 | Last-Modified: 40 | - Sun, 10 Dec 2023 01:37:27 GMT 41 | Referrer-Policy: 42 | - origin-when-cross-origin, strict-origin-when-cross-origin 43 | Server: 44 | - GitHub.com 45 | Strict-Transport-Security: 46 | - max-age=31536000; includeSubdomains; preload 47 | Transfer-Encoding: 48 | - chunked 49 | Vary: 50 | - Accept, Authorization, Cookie, X-GitHub-OTP 51 | - Accept-Encoding, Accept, X-Requested-With 52 | X-Accepted-OAuth-Scopes: 53 | - "" 54 | X-Content-Type-Options: 55 | - nosniff 56 | X-Frame-Options: 57 | - deny 58 | X-GitHub-Media-Type: 59 | - github.v3; format=json 60 | X-GitHub-Request-Id: 61 | - C116:12DF:59944FF:B9320C7:6584F700 62 | X-OAuth-Scopes: 63 | - admin:gpg_key, admin:org, admin:public_key, admin:repo_hook, delete:packages, 64 | delete_repo, gist, notifications, project, repo, user, workflow, write:packages 65 | X-RateLimit-Limit: 66 | - "5000" 67 | X-RateLimit-Remaining: 68 | - "4986" 69 | X-RateLimit-Reset: 70 | - "1703215491" 71 | X-RateLimit-Resource: 72 | - core 73 | X-RateLimit-Used: 74 | - "14" 75 | X-XSS-Protection: 76 | - "0" 77 | x-github-api-version-selected: 78 | - "2022-11-28" 79 | status: 80 | code: 200 81 | message: OK 82 | - request: 83 | body: null 84 | headers: 85 | Accept: 86 | - "*/*" 87 | Accept-Encoding: 88 | - gzip, deflate 89 | Connection: 90 | - keep-alive 91 | User-Agent: 92 | - python-requests/2.31.0 93 | authorization: 94 | - XXXXXXXXXX 95 | method: GET 96 | uri: https://api.github.com/repos/juftin/browsr/git/trees/v1.6.0 97 | response: 98 | body: 99 | string: '{"sha":"b80ea1937572041e2ed884f3d17919a7324b080b","url":"https://api.github.com/repos/juftin/browsr/git/trees/b80ea1937572041e2ed884f3d17919a7324b080b","tree":[{"path":".github","mode":"040000","type":"tree","sha":"a901aed96b47ecefdef6bf3530856065a76ff9f8","url":"https://api.github.com/repos/juftin/browsr/git/trees/a901aed96b47ecefdef6bf3530856065a76ff9f8"},{"path":".gitignore","mode":"100644","type":"blob","sha":"7e0d4d450a35a973ed5d4d1eeb749861ffb68ecf","size":2804,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/7e0d4d450a35a973ed5d4d1eeb749861ffb68ecf"},{"path":".pre-commit-config.yaml","mode":"100644","type":"blob","sha":"a9d8704a8c8511e6a430032ec2615c3dfeb7fb84","size":1379,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/a9d8704a8c8511e6a430032ec2615c3dfeb7fb84"},{"path":".releaserc.js","mode":"100644","type":"blob","sha":"71ebbd3ec251758bf9e3ac6b1c246808af9d289a","size":1574,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/71ebbd3ec251758bf9e3ac6b1c246808af9d289a"},{"path":"LICENSE","mode":"100644","type":"blob","sha":"3f1116db9ccaba831afbd47542a8dbef1ceb2189","size":1109,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/3f1116db9ccaba831afbd47542a8dbef1ceb2189"},{"path":"README.md","mode":"100644","type":"blob","sha":"1b7227281e1ac90b906c05a82ce968b6d5e3114c","size":2728,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/1b7227281e1ac90b906c05a82ce968b6d5e3114c"},{"path":"browsr","mode":"040000","type":"tree","sha":"dd4f0ba68388bc8382120b638a14497bafd3945b","url":"https://api.github.com/repos/juftin/browsr/git/trees/dd4f0ba68388bc8382120b638a14497bafd3945b"},{"path":"docs","mode":"040000","type":"tree","sha":"de31dc85a413f8f2cdd79130276faac5e93efcc9","url":"https://api.github.com/repos/juftin/browsr/git/trees/de31dc85a413f8f2cdd79130276faac5e93efcc9"},{"path":"mkdocs.yaml","mode":"100644","type":"blob","sha":"d5151fff1451011cdac254c3745e15e78af77064","size":2183,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/d5151fff1451011cdac254c3745e15e78af77064"},{"path":"pyproject.toml","mode":"100644","type":"blob","sha":"98be6018858bf4ccf1dafa7509d388ea08537ed7","size":4448,"url":"https://api.github.com/repos/juftin/browsr/git/blobs/98be6018858bf4ccf1dafa7509d388ea08537ed7"},{"path":"requirements","mode":"040000","type":"tree","sha":"3b044dfacdcb0bd4b3d9b032363d135c5894e410","url":"https://api.github.com/repos/juftin/browsr/git/trees/3b044dfacdcb0bd4b3d9b032363d135c5894e410"},{"path":"tests","mode":"040000","type":"tree","sha":"c25588f3e19c20ee684b21551db7635b155b3cf9","url":"https://api.github.com/repos/juftin/browsr/git/trees/c25588f3e19c20ee684b21551db7635b155b3cf9"}],"truncated":false}' 100 | headers: 101 | Access-Control-Allow-Origin: 102 | - "*" 103 | Access-Control-Expose-Headers: 104 | - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, 105 | X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, 106 | X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, 107 | X-GitHub-Request-Id, Deprecation, Sunset 108 | Cache-Control: 109 | - private, max-age=60, s-maxage=60 110 | 111 | Content-Security-Policy: 112 | - default-src 'none' 113 | Content-Type: 114 | - application/json; charset=utf-8 115 | Date: 116 | - Fri, 22 Dec 2023 02:40:00 GMT 117 | ETag: 118 | - W/"bbfd14c82ededc5d6619f1ca1d1aa8852c6efbee36a352a067516ea4c77807ca" 119 | Last-Modified: 120 | - Sun, 10 Dec 2023 01:37:27 GMT 121 | Referrer-Policy: 122 | - origin-when-cross-origin, strict-origin-when-cross-origin 123 | Server: 124 | - GitHub.com 125 | Strict-Transport-Security: 126 | - max-age=31536000; includeSubdomains; preload 127 | Transfer-Encoding: 128 | - chunked 129 | Vary: 130 | - Accept, Authorization, Cookie, X-GitHub-OTP 131 | - Accept-Encoding, Accept, X-Requested-With 132 | X-Accepted-OAuth-Scopes: 133 | - "" 134 | X-Content-Type-Options: 135 | - nosniff 136 | X-Frame-Options: 137 | - deny 138 | X-GitHub-Media-Type: 139 | - github.v3; format=json 140 | X-GitHub-Request-Id: 141 | - C117:585F:8073BEC:108E07BE:6584F700 142 | X-OAuth-Scopes: 143 | - admin:gpg_key, admin:org, admin:public_key, admin:repo_hook, delete:packages, 144 | delete_repo, gist, notifications, project, repo, user, workflow, write:packages 145 | X-RateLimit-Limit: 146 | - "5000" 147 | X-RateLimit-Remaining: 148 | - "4985" 149 | X-RateLimit-Reset: 150 | - "1703215491" 151 | X-RateLimit-Resource: 152 | - core 153 | X-RateLimit-Used: 154 | - "15" 155 | X-XSS-Protection: 156 | - "0" 157 | x-github-api-version-selected: 158 | - "2022-11-28" 159 | status: 160 | code: 200 161 | message: OK 162 | - request: 163 | body: null 164 | headers: 165 | Accept: 166 | - "*/*" 167 | Accept-Encoding: 168 | - gzip, deflate 169 | Connection: 170 | - keep-alive 171 | User-Agent: 172 | - python-requests/2.28.2 173 | method: GET 174 | uri: https://raw.githubusercontent.com/juftin/browsr/v1.6.0/README.md 175 | response: 176 | body: 177 | string: 178 | "# browsr\n\n
\n 179 | \ \n \"browsr\"\n \n
\n\n[![browsr Version](https://img.shields.io/pypi/v/browsr?color=blue&label=browsr)](https://github.com/juftin/browsr)\n[![PyPI](https://img.shields.io/pypi/pyversions/browsr)](https://pypi.python.org/pypi/browsr/)\n[![Testing 181 | Status](https://github.com/juftin/browsr/actions/workflows/tests.yaml/badge.svg?branch=main)](https://github.com/juftin/browsr/actions/workflows/tests.yaml?query=branch%3Amain)\n[![GitHub 182 | License](https://img.shields.io/github/license/juftin/browsr?color=blue&label=License)](https://github.com/juftin/browsr/blob/main/LICENSE)\n\n**`browsr`** 183 | is a TUI (text-based user interface) file browser for your terminal.\nIt's 184 | a simple way to browse your files and take a peek at their contents. Plus 185 | it\nworks on local and remote file systems.\n\n
\n\n\n
\n 187 | \ \"Image\n \"Image\n \"Image\n \"Image\n
\n\n\n
\n\n## Installation\n\nThe 192 | below command recommends [pipx](https://pypa.github.io/pipx/) instead of pip. 193 | `pipx` installs the package in\nan isolated environment and makes it easy 194 | to uninstall. If you'd like to use `pip` instead, just replace `pipx`\nwith 195 | `pip` in the below command.\n\n```shell\npipx install browsr\n```\n\n## Extra 196 | Installation\n\nIf you're looking to use **`browsr`** on remote file systems, 197 | like AWS S3, you'll need to install the `remote` extra.\nIf you'd like to 198 | browse parquet files, you'll need to install the `parquet` extra. Or, even 199 | simpler,\nyou can install the `all` extra to get all the extras.\n\n```shell\npipx 200 | install \"browsr[all]\"\n```\n\n## Usage\n\n```shell\nbrowsr ~/Downloads/\n```\n\nSimply 201 | give **`browsr`** a path to a file/directory and it will open a browser window\nwith 202 | a file browser. You can also give it a URL to a remote file system, like AWS 203 | S3.\n\n```shell\nbrowsr s3://my-bucket/my-file.parquet\n```\n\n### [Check 204 | out the Documentation](https://juftin.com/browsr/) for more\n\n## License\n\n**`browsr`** 205 | is distributed under the terms of the [MIT license](LICENSE).\n" 206 | headers: 207 | Accept-Ranges: 208 | - bytes 209 | Access-Control-Allow-Origin: 210 | - "*" 211 | Cache-Control: 212 | - max-age=300 213 | Connection: 214 | - keep-alive 215 | 216 | Content-Length: 217 | - "1101" 218 | Content-Security-Policy: 219 | - default-src 'none'; style-src 'unsafe-inline'; sandbox 220 | Content-Type: 221 | - text/plain; charset=utf-8 222 | Date: 223 | - Thu, 18 May 2023 01:01:44 GMT 224 | ETag: 225 | - W/"e3f73ab855fd90588244244759492697dae0221aaa9e819693338d2cd1ad119e" 226 | Expires: 227 | - Thu, 18 May 2023 01:06:44 GMT 228 | Source-Age: 229 | - "0" 230 | Strict-Transport-Security: 231 | - max-age=31536000 232 | Vary: 233 | - Authorization,Accept-Encoding,Origin 234 | Via: 235 | - 1.1 varnish 236 | X-Cache: 237 | - MISS 238 | X-Cache-Hits: 239 | - "0" 240 | X-Content-Type-Options: 241 | - nosniff 242 | X-Fastly-Request-ID: 243 | - 7459b3f705b11601e933e9db121561cfa0dae324 244 | X-Frame-Options: 245 | - deny 246 | X-GitHub-Request-Id: 247 | - F40E:6E4E:3A21014:443B1B9:646578F8 248 | X-Served-By: 249 | - cache-den8272-DEN 250 | X-Timer: 251 | - S1684371705.820747,VS0,VE131 252 | X-XSS-Protection: 253 | - 1; mode=block 254 | status: 255 | code: 200 256 | message: OK 257 | version: 1 258 | -------------------------------------------------------------------------------- /tests/cassettes/test_github_screenshot_license.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - "*/*" 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | authorization: 14 | - XXXXXXXXXX 15 | method: GET 16 | uri: https://raw.githubusercontent.com/juftin/browsr/v1.6.0/LICENSE 17 | response: 18 | body: 19 | string: 'MIT License 20 | 21 | 22 | Copyright (c) 2023-present Justin Flannery 23 | 24 | 25 | Permission is hereby granted, free of charge, to any person obtaining a copy 26 | of this software and associated documentation files (the "Software"), to deal 27 | in the Software without restriction, including without limitation the rights 28 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 29 | copies of the Software, and to permit persons to whom the Software is furnished 30 | to do so, subject to the following conditions: 31 | 32 | 33 | The above copyright notice and this permission notice shall be included in 34 | all copies or substantial portions of the Software. 35 | 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 39 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 40 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 41 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 42 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 43 | 44 | ' 45 | headers: 46 | Accept-Ranges: 47 | - bytes 48 | Access-Control-Allow-Origin: 49 | - "*" 50 | Cache-Control: 51 | - max-age=300 52 | Connection: 53 | - keep-alive 54 | 55 | Content-Length: 56 | - "664" 57 | Content-Security-Policy: 58 | - default-src 'none'; style-src 'unsafe-inline'; sandbox 59 | Content-Type: 60 | - text/plain; charset=utf-8 61 | Cross-Origin-Resource-Policy: 62 | - cross-origin 63 | Date: 64 | - Fri, 22 Dec 2023 02:35:37 GMT 65 | ETag: 66 | - W/"7317880eca0696f93222e0968307748f383c87a2a37112679931db91bdd5f000" 67 | Expires: 68 | - Fri, 22 Dec 2023 02:40:37 GMT 69 | Source-Age: 70 | - "0" 71 | Strict-Transport-Security: 72 | - max-age=31536000 73 | Vary: 74 | - Authorization,Accept-Encoding,Origin 75 | Via: 76 | - 1.1 varnish 77 | X-Cache: 78 | - HIT 79 | X-Cache-Hits: 80 | - "1" 81 | X-Content-Type-Options: 82 | - nosniff 83 | X-Fastly-Request-ID: 84 | - 610728dd0ab1b2a88737c8cc0d1fb651715a5424 85 | X-Frame-Options: 86 | - deny 87 | X-GitHub-Request-Id: 88 | - F05E:573D:112B22D:1495B2F:6584F370 89 | X-Served-By: 90 | - cache-den8245-DEN 91 | X-Timer: 92 | - S1703212537.491474,VS0,VE154 93 | X-XSS-Protection: 94 | - 1; mode=block 95 | status: 96 | code: 200 97 | message: OK 98 | version: 1 99 | -------------------------------------------------------------------------------- /tests/cassettes/test_mkdocs_screenshot.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - "*/*" 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | authorization: 14 | - XXXXXXXXXX 15 | method: GET 16 | uri: https://raw.githubusercontent.com/juftin/browsr/v1.6.0/mkdocs.yaml 17 | response: 18 | body: 19 | string: 20 | "# schema: https://squidfunk.github.io/mkdocs-material/schema.json\n\nsite_name: 21 | browsr\nnav:\n - index.md\n - Command Line Interface \u2328\uFE0F: cli.md\n 22 | \ - Contributing \U0001F91D: contributing.md\n - API Documentation \U0001F916: 23 | reference/\ntheme:\n favicon: https://raw.githubusercontent.com/juftin/browsr/main/docs/_static/browsr_no_label.png\n 24 | \ logo: https://raw.githubusercontent.com/juftin/browsr/main/docs/_static/browsr_no_label.png\n 25 | \ name: material\n features:\n - navigation.tracking\n - 26 | content.code.annotate\n - content.code.copy\n palette:\n - 27 | media: \"(prefers-color-scheme: light)\"\n scheme: default\n accent: 28 | purple\n toggle:\n icon: material/weather-sunny\n name: 29 | Switch to dark mode\n - media: \"(prefers-color-scheme: dark)\"\n scheme: 30 | slate\n primary: black\n toggle:\n icon: material/weather-night\n 31 | \ name: Switch to light mode\nrepo_url: https://github.com/juftin/browsr\nrepo_name: 32 | browsr\nedit_uri: blob/main/docs/\nsite_author: Justin Flannery\nremote_branch: 33 | gh-pages\ncopyright: Copyright \xA9 2023 Justin Flannery\nextra:\n generator: 34 | false\nmarkdown_extensions:\n - toc:\n permalink: \"#\"\n - 35 | pymdownx.snippets\n - pymdownx.magiclink\n - attr_list\n - md_in_html\n 36 | \ - pymdownx.highlight:\n anchor_linenums: true\n - pymdownx.inlinehilite\n 37 | \ - pymdownx.superfences\n - markdown.extensions.attr_list\n - pymdownx.keys\n 38 | \ - pymdownx.tasklist:\n custom_checkbox: true\n - pymdownx.tilde\n 39 | \ - admonition\n - pymdownx.details\n - mkdocs-click\nplugins:\n - 40 | search\n - gen-files:\n scripts:\n - docs/gen_ref_pages.py\n 41 | \ - literate-nav:\n nav_file: SUMMARY.md\n - section-index:\n 42 | \ - mkdocstrings:\n handlers:\n python:\n import:\n 43 | \ - https://docs.python.org/3/objects.inv\n - 44 | https://numpy.org/doc/stable/objects.inv\n - https://pandas.pydata.org/docs/objects.inv\n 45 | \ options:\n docstring_style: numpy\n 46 | \ filters: []\n" 47 | headers: 48 | Accept-Ranges: 49 | - bytes 50 | Access-Control-Allow-Origin: 51 | - "*" 52 | Cache-Control: 53 | - max-age=300 54 | Connection: 55 | - keep-alive 56 | 57 | Content-Length: 58 | - "879" 59 | Content-Security-Policy: 60 | - default-src 'none'; style-src 'unsafe-inline'; sandbox 61 | Content-Type: 62 | - text/plain; charset=utf-8 63 | Cross-Origin-Resource-Policy: 64 | - cross-origin 65 | Date: 66 | - Fri, 22 Dec 2023 03:03:57 GMT 67 | ETag: 68 | - W/"95920d25cd145c3ee86d6eecaab797adc36dbe478ad9976c065b7b0ece18ff37" 69 | Expires: 70 | - Fri, 22 Dec 2023 03:08:57 GMT 71 | Source-Age: 72 | - "0" 73 | Strict-Transport-Security: 74 | - max-age=31536000 75 | Vary: 76 | - Authorization,Accept-Encoding,Origin 77 | Via: 78 | - 1.1 varnish 79 | X-Cache: 80 | - MISS 81 | X-Cache-Hits: 82 | - "0" 83 | X-Content-Type-Options: 84 | - nosniff 85 | X-Fastly-Request-ID: 86 | - 11e6b224a6be10f8c7b3560ea4e9b30f5c65b73b 87 | X-Frame-Options: 88 | - deny 89 | X-GitHub-Request-Id: 90 | - 0B6E:7FD9:125D10A:15C3F3F:6584FC9C 91 | X-Served-By: 92 | - cache-den8236-DEN 93 | X-Timer: 94 | - S1703214237.880066,VS0,VE378 95 | X-XSS-Protection: 96 | - 1; mode=block 97 | status: 98 | code: 200 99 | message: OK 100 | version: 1 101 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pytest Fixtures Shared Across all Unit Tests 3 | """ 4 | 5 | from typing import Any, Dict, List 6 | 7 | import pyperclip 8 | import pytest 9 | from click.testing import CliRunner 10 | from textual_universal_directorytree import GitHubTextualPath, UPath 11 | 12 | 13 | @pytest.fixture 14 | def runner() -> CliRunner: 15 | """ 16 | Return a CliRunner object 17 | """ 18 | return CliRunner() 19 | 20 | 21 | @pytest.fixture 22 | def repo_dir() -> UPath: 23 | """ 24 | Return the path to the repository root 25 | """ 26 | return UPath(__file__).parent.parent.resolve() 27 | 28 | 29 | @pytest.fixture 30 | def screenshot_dir(repo_dir: UPath) -> UPath: 31 | """ 32 | Return the path to the screenshot directory 33 | """ 34 | return repo_dir / "tests" / "screenshots" 35 | 36 | 37 | @pytest.fixture 38 | def github_release_path() -> GitHubTextualPath: 39 | """ 40 | Return the path to the Github Release 41 | """ 42 | release = "v1.6.0" 43 | uri = f"github://juftin:browsr@{release}" 44 | return GitHubTextualPath(uri) 45 | 46 | 47 | @pytest.fixture(autouse=True) 48 | def copy_supported(monkeypatch: pytest.MonkeyPatch) -> None: 49 | """ 50 | Override _copy_supported 51 | """ 52 | monkeypatch.setattr( 53 | pyperclip, "determine_clipboard", lambda: (lambda: True, lambda: True) 54 | ) 55 | 56 | 57 | @pytest.fixture(scope="module") 58 | def vcr_config() -> Dict[str, List[Any]]: 59 | """ 60 | VCR Cassette Privacy Enforcer 61 | 62 | This fixture ensures the API Credentials are obfuscated 63 | 64 | Returns 65 | ------- 66 | Dict[str, list]: 67 | """ 68 | return { 69 | "filter_headers": [("authorization", "XXXXXXXXXX")], 70 | "filter_query_parameters": [("user", "XXXXXXXXXX"), ("token", "XXXXXXXXXX")], 71 | } 72 | 73 | 74 | cassette = pytest.mark.vcr(scope="module") 75 | -------------------------------------------------------------------------------- /tests/debug_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | App Debugging 3 | """ 4 | 5 | import pytest 6 | 7 | from browsr.base import TextualAppContext 8 | from browsr.browsr import Browsr 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_debug_app() -> None: 13 | """ 14 | Test the actual browsr app 15 | """ 16 | config = TextualAppContext(file_path=".", debug=True) 17 | app = Browsr(config_object=config) 18 | async with app.run_test() as pilot: 19 | _ = pilot.app 20 | -------------------------------------------------------------------------------- /tests/test_browsr.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the actual browsr app 3 | """ 4 | 5 | import pytest 6 | 7 | from browsr.base import TextualAppContext 8 | from browsr.browsr import Browsr 9 | 10 | 11 | def test_bad_path() -> None: 12 | """ 13 | Test a bad path 14 | """ 15 | with pytest.raises(FileNotFoundError): 16 | _ = Browsr( 17 | config_object=TextualAppContext(file_path="bad_file_path.csv", debug=True) 18 | ) 19 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing of the Demo Class 3 | """ 4 | 5 | from click.testing import CliRunner 6 | 7 | from browsr.cli import browsr 8 | 9 | 10 | def test_cli_main(runner: CliRunner) -> None: 11 | """ 12 | Test the main function of the CLI 13 | """ 14 | result = runner.invoke(browsr, ["--help"]) 15 | assert result.exit_code == 0 16 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Config / Context Tests 3 | """ 4 | import pathlib 5 | from dataclasses import is_dataclass 6 | 7 | from textual_universal_directorytree import GitHubTextualPath, UPath 8 | 9 | from browsr.base import TextualAppContext 10 | from browsr.config import favorite_themes 11 | from tests.conftest import cassette 12 | 13 | 14 | def test_favorite_themes() -> None: 15 | """ 16 | Test that favorite_themes is a list of strings 17 | """ 18 | assert isinstance(favorite_themes, list) 19 | for theme in favorite_themes: 20 | assert isinstance(theme, str) 21 | 22 | 23 | def test_textual_app_context() -> None: 24 | """ 25 | Test that TextualAppContext is a dataclass 26 | """ 27 | assert is_dataclass(TextualAppContext) 28 | 29 | 30 | def test_textual_app_context_path() -> None: 31 | """ 32 | Test that default TextualAppContext.path is CWD 33 | """ 34 | context = TextualAppContext() 35 | assert isinstance(context.path, UPath) 36 | assert context.path == pathlib.Path.cwd().resolve() 37 | 38 | 39 | @cassette 40 | def test_textual_app_context_path_github() -> None: 41 | """ 42 | Test GitHub URL Parsing 43 | """ 44 | github_strings = [ 45 | "https://github.com/juftin/browsr", 46 | "https://github.com/juftin/browsr.git", 47 | "github.com/juftin/browsr", 48 | "www.github.com/juftin/browsr", 49 | "https://www.github.com/juftin/browsr", 50 | "github://juftin:browsr", 51 | "github://juftin:browsr@main", 52 | ] 53 | for _github_string in github_strings: 54 | context = TextualAppContext(file_path=_github_string) 55 | handled_github_url = context.path 56 | expected_file_path = "github://juftin:browsr@main/" 57 | assert handled_github_url == GitHubTextualPath(expected_file_path) 58 | assert str(handled_github_url) == expected_file_path 59 | -------------------------------------------------------------------------------- /tests/test_screenshots.py: -------------------------------------------------------------------------------- 1 | """ 2 | Screenshot Testing Using Cassettes! 3 | """ 4 | 5 | from textwrap import dedent 6 | from typing import Callable, Tuple 7 | 8 | import pytest 9 | from textual_universal_directorytree import GitHubTextualPath, UPath 10 | 11 | from tests.conftest import cassette 12 | 13 | 14 | @pytest.fixture 15 | def app_file() -> str: 16 | file_content = """ 17 | from browsr.browsr import Browsr 18 | from browsr.base import TextualAppContext 19 | 20 | file_path = "{file_path}" 21 | context = TextualAppContext(file_path=file_path) 22 | app = Browsr(config_object=context) 23 | """ 24 | return dedent(file_content).strip() 25 | 26 | 27 | @pytest.fixture 28 | def terminal_size() -> Tuple[int, int]: 29 | return 160, 48 30 | 31 | 32 | @cassette 33 | def test_github_screenshot( 34 | snap_compare: Callable[..., bool], 35 | tmp_path: UPath, 36 | app_file: str, 37 | github_release_path: GitHubTextualPath, 38 | terminal_size: Tuple[int, int], 39 | ) -> None: 40 | """ 41 | Snapshot a release of this repo 42 | """ 43 | app_path = tmp_path / "app.py" 44 | app_path.write_text(app_file.format(file_path=str(github_release_path))) 45 | assert snap_compare(app_path=app_path, terminal_size=terminal_size) 46 | 47 | 48 | @cassette 49 | def test_github_screenshot_license( 50 | snap_compare: Callable[..., bool], 51 | tmp_path: UPath, 52 | app_file: str, 53 | github_release_path: GitHubTextualPath, 54 | terminal_size: Tuple[int, int], 55 | ) -> None: 56 | """ 57 | Snapshot the LICENSE file 58 | """ 59 | file_path = str(github_release_path / "LICENSE") 60 | app_path = tmp_path / "app.py" 61 | app_path.write_text(app_file.format(file_path=file_path)) 62 | assert snap_compare(app_path=app_path, terminal_size=terminal_size) 63 | 64 | 65 | @cassette 66 | def test_mkdocs_screenshot( 67 | snap_compare: Callable[..., bool], 68 | tmp_path: UPath, 69 | app_file: str, 70 | terminal_size: Tuple[int, int], 71 | github_release_path: GitHubTextualPath, 72 | ) -> None: 73 | """ 74 | Snapshot the pyproject.toml file 75 | """ 76 | file_path = str(github_release_path / "mkdocs.yaml") 77 | app_path = tmp_path / "app.py" 78 | app_path.write_text(app_file.format(file_path=file_path)) 79 | assert snap_compare(app_path=app_path, terminal_size=terminal_size) 80 | --------------------------------------------------------------------------------