├── src └── ped │ ├── py.typed │ ├── ped_bash_completion.sh │ ├── pypath.py │ ├── style.py │ ├── ped_zsh_completion.zsh │ ├── install_completion.py │ ├── guess_module.py │ └── __init__.py ├── tests ├── __init__.py └── test_ped.py ├── .github ├── dependabot.yml └── workflows │ └── build-release.yml ├── RELEASING.md ├── tox.ini ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── pyproject.toml ├── NOTICE └── README.rst /src/ped/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Bump version in `pyproject.toml` and update the changelog 4 | with today's date. 5 | 2. Commit: `git commit -m "Bump version and update changelog"` 6 | 3. Tag the commit: `git tag x.y.z` 7 | 4. Push: `git push --tags origin main`. CI will take care of the 8 | PyPI release. 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = lint,py38,py39,py310,py311,py312 4 | 5 | [testenv] 6 | extras = tests 7 | commands = pytest {posargs} 8 | 9 | [testenv:lint] 10 | deps = pre-commit~=3.5 11 | skip_install = true 12 | commands = pre-commit run --all-files 13 | 14 | ; Below tasks are for development only (not run in CI) 15 | [testenv:watch-readme] 16 | deps = restview 17 | skip_install = true 18 | commands = restview README.rst 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: v0.14.7 6 | hooks: 7 | - id: ruff 8 | - id: ruff-format 9 | - repo: https://github.com/python-jsonschema/check-jsonschema 10 | rev: 0.35.0 11 | hooks: 12 | - id: check-github-workflows 13 | - id: check-readthedocs 14 | - repo: https://github.com/asottile/blacken-docs 15 | rev: 1.20.0 16 | hooks: 17 | - id: blacken-docs 18 | additional_dependencies: [black==23.12.1] 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v1.19.0 21 | hooks: 22 | - id: mypy 23 | -------------------------------------------------------------------------------- /src/ped/ped_bash_completion.sh: -------------------------------------------------------------------------------- 1 | # Bash completion for ped: 2 | # 3 | # ped [module name] 4 | # 5 | _complete_ped() 6 | { 7 | local cur prev opts 8 | COMPREPLY=() 9 | cur="${COMP_WORDS[COMP_CWORD]}" 10 | prev="${COMP_WORDS[COMP_CWORD-1]}" 11 | opts="--editor --info --version" 12 | 13 | # --foo options 14 | if [[ "${cur::1}" == "-" ]] 15 | then 16 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 17 | return 0 18 | fi 19 | 20 | case "${prev}" in 21 | -e|--editor) 22 | # Complete commands for editor flag 23 | COMPREPLY=( $(compgen -c ${cur}) ) 24 | return 0 25 | ;; 26 | *) 27 | ;; 28 | esac 29 | 30 | # Complete a module name 31 | COMPREPLY=( $(ped --complete ${cur}) ) 32 | } 33 | complete -F _complete_ped ped 34 | -------------------------------------------------------------------------------- /src/ped/pypath.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | import sys 4 | from typing import List 5 | 6 | 7 | def patch_sys_path() -> None: 8 | """Modify sys.path to include all paths from the 9 | current environment. 10 | """ 11 | syspath = _get_external_sys_path() 12 | for each in reversed(syspath): 13 | if each not in sys.path: 14 | sys.path.insert(0, each) 15 | 16 | 17 | def _get_external_sys_path() -> List[str]: 18 | executable = shutil.which("python") or "python" 19 | if executable == sys.executable: # not in virtualenv 20 | return [] 21 | ret = ( 22 | subprocess.run( 23 | [executable, "-c", "import sys; print(','.join(sys.path))"], 24 | stdout=subprocess.PIPE, 25 | stderr=subprocess.DEVNULL, 26 | ) 27 | .stdout.decode() 28 | .strip() 29 | ) 30 | return ret.split(",") 31 | -------------------------------------------------------------------------------- /src/ped/style.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import IO, Any, Optional 4 | 5 | RED = 31 6 | GREEN = 32 7 | BOLD = 1 8 | RESET_ALL = 0 9 | 10 | 11 | def style( 12 | text: str, fg: Optional[int] = None, *, bold: bool = False, file: IO = sys.stdout 13 | ) -> str: 14 | use_color = not os.environ.get("NO_COLOR") and file.isatty() 15 | if use_color: 16 | parts = [ 17 | fg and f"\033[{fg}m", 18 | bold and f"\033[{BOLD}m", 19 | text, 20 | f"\033[{RESET_ALL}m", 21 | ] 22 | return "".join([e for e in parts if e]) 23 | else: 24 | return text 25 | 26 | 27 | def sprint(text: str, *args: Any, **kwargs: Any) -> None: 28 | file = kwargs.pop("file", sys.stdout) 29 | return print(style(text, *args, **kwargs, file=file), file=file) 30 | 31 | 32 | def print_error(text: str) -> None: 33 | prefix = style("ERROR", RED, file=sys.stderr) 34 | return sprint(f"{prefix}: {text}", file=sys.stderr) 35 | -------------------------------------------------------------------------------- /src/ped/ped_zsh_completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef ped 2 | # vim: ft=zsh sw=2 ts=2 et 3 | # ZSH completion file for ped. 4 | 5 | _ped() { 6 | local curcontext="$curcontext" state line 7 | integer NORMARG 8 | typeset -A opt_args 9 | 10 | _arguments -C -s -n \ 11 | '(- -h --help)'{-h,--help}'[Show help message]' \ 12 | '(- -v --version)'{-v,--version}'[Print version and exit]' \ 13 | '(- -e --editor)'{-e,--editor}'[Editor to use]' \ 14 | '(- -i --info)'{-i,--info}'[Print module name, path, and line number]' \ 15 | '1: :->module' 16 | 17 | case $state in 18 | module) 19 | if [[ CURRENT -eq NORMARG ]] 20 | then 21 | # If the current argument is the first non-option argument 22 | # then complete with python modules 23 | cmds=( ${(uf)"$(ped ${words[CURRENT]} --complete)"} ) 24 | _arguments '1:modules:(${cmds})' 25 | _message -e patterns 'pattern' && ret=0 26 | fi 27 | ;; 28 | *) 29 | esac 30 | } 31 | 32 | _ped "$@" 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Steven Loria 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ########## Generated by gig ########### 2 | 3 | ### Python ### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | pip-wheel-metadata 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | 64 | .konchrc 65 | test-output 66 | .mypy_cache 67 | 68 | # ruff 69 | .ruff_cache 70 | -------------------------------------------------------------------------------- /src/ped/install_completion.py: -------------------------------------------------------------------------------- 1 | """Script to install bash and zsh completion for ped.""" 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | from .style import print_error, style 8 | 9 | HERE = Path(__file__).parent 10 | SHELL_MAP = { 11 | "bash": HERE / "ped_bash_completion.sh", 12 | "zsh": HERE / "ped_zsh_completion.zsh", 13 | } 14 | 15 | 16 | def main() -> None: 17 | if "SHELL" not in os.environ or not os.environ.get("SHELL"): 18 | print_error("Must have $SHELL set.") 19 | example = style( 20 | "SHELL=bash python -m scripts.install_completion", 21 | bold=True, 22 | file=sys.stderr, 23 | ) 24 | print(f"Example: {example}", file=sys.stderr) 25 | sys.exit(1) 26 | shell_path = Path(os.environ["SHELL"]) 27 | shell = Path(shell_path).stem 28 | if shell not in SHELL_MAP: 29 | print_error( 30 | f'"{shell_path}" not supported. Only bash and zsh are currently supported.' 31 | ) 32 | sys.exit(1) 33 | completion_path = SHELL_MAP[shell] 34 | with completion_path.open("r") as fp: 35 | print(fp.read(), end="") 36 | sys.exit(0) 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ped" 3 | version = "3.0.0" 4 | description = "Quickly open Python modules in your text editor." 5 | readme = "README.rst" 6 | license = { file = "LICENSE" } 7 | authors = [{ name = "Steven Loria", email = "sloria1@gmail.com" }] 8 | classifiers = [ 9 | "Intended Audience :: Developers", 10 | "License :: OSI Approved :: MIT License", 11 | "Natural Language :: English", 12 | "Programming Language :: Python :: 3", 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 | "Topic :: System :: Shells", 19 | ] 20 | keywords = ["commandline", "cli", "open", "editor", "editing"] 21 | requires-python = ">=3.8" 22 | 23 | [project.scripts] 24 | ped = "ped:main" 25 | 26 | [project.urls] 27 | Issues = "https://github.com/sloria/ped/issues" 28 | Source = "https://github.com/sloria/ped/" 29 | 30 | [project.optional-dependencies] 31 | dev = ["ped[tests]", "tox"] 32 | tests = ["pytest", "pytest-mock", "scripttest==2.0.post1"] 33 | 34 | [build-system] 35 | requires = ["flit_core<4"] 36 | build-backend = "flit_core.buildapi" 37 | 38 | [tool.flit.sdist] 39 | include = ["tests/", "NOTICE", "tox.ini"] 40 | exclude = ["tests/test-output"] 41 | 42 | [tool.ruff] 43 | src = ["src"] 44 | fix = true 45 | show-fixes = true 46 | output-format = "full" 47 | 48 | [tool.ruff.lint] 49 | select = [ 50 | "B", # flake8-bugbear 51 | "E", # pycodestyle error 52 | "F", # pyflakes 53 | "I", # isort 54 | "UP", # pyupgrade 55 | "W", # pycodestyle warning 56 | ] 57 | 58 | [tool.mypy] 59 | ignore_missing_imports = true 60 | warn_unreachable = true 61 | warn_unused_ignores = true 62 | warn_redundant_casts = true 63 | no_implicit_optional = true 64 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: ["main"] 5 | tags: ["*"] 6 | pull_request: 7 | 8 | jobs: 9 | tests: 10 | name: ${{ matrix.name }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - { name: "3.8", python: "3.8", tox: py38 } 17 | - { name: "3.12", python: "3.12", tox: py312 } 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: actions/setup-python@v6 21 | with: 22 | python-version: ${{ matrix.python }} 23 | - run: python -m pip install tox 24 | - run: python -m tox -e ${{ matrix.tox }} 25 | build: 26 | name: Build package 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v6 30 | - uses: actions/setup-python@v6 31 | with: 32 | python-version: "3.11" 33 | - name: Install pypa/build 34 | run: python -m pip install build 35 | - name: Build a binary wheel and a source tarball 36 | run: python -m build 37 | - name: Install twine 38 | run: python -m pip install twine 39 | - name: Check build 40 | run: python -m twine check --strict dist/* 41 | - name: Store the distribution packages 42 | uses: actions/upload-artifact@v5 43 | with: 44 | name: python-package-distributions 45 | path: dist/ 46 | # this duplicates pre-commit.ci, so only run it on tags 47 | # it guarantees that linting is passing prior to a release 48 | lint-pre-release: 49 | if: startsWith(github.ref, 'refs/tags') 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v6 53 | - uses: actions/setup-python@v6 54 | with: 55 | python-version: "3.11" 56 | - run: python -m pip install tox 57 | - run: python -m tox -elint 58 | publish-to-pypi: 59 | name: PyPI release 60 | if: startsWith(github.ref, 'refs/tags/') 61 | needs: [build, tests, lint-pre-release] 62 | runs-on: ubuntu-latest 63 | environment: 64 | name: pypi 65 | url: https://pypi.org/p/ped 66 | permissions: 67 | id-token: write 68 | steps: 69 | - name: Download all the dists 70 | uses: actions/download-artifact@v6 71 | with: 72 | name: python-package-distributions 73 | path: dist/ 74 | - name: Publish distribution to PyPI 75 | uses: pypa/gh-action-pypi-publish@release/v1 76 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Some code adapted from IPython and click. 2 | 3 | 4 | IPython License 5 | =============== 6 | 7 | IPython is licensed under the terms of the Modified BSD License (also known as 8 | New or Revised or 3-Clause BSD), as follows: 9 | 10 | - Copyright (c) 2008-2014, IPython Development Team 11 | - Copyright (c) 2001-2007, Fernando Perez 12 | - Copyright (c) 2001, Janko Hauser 13 | - Copyright (c) 2001, Nathaniel Gray 14 | 15 | All rights reserved. 16 | 17 | Redistribution and use in source and binary forms, with or without 18 | modification, are permitted provided that the following conditions are met: 19 | 20 | Redistributions of source code must retain the above copyright notice, this 21 | list of conditions and the following disclaimer. 22 | 23 | Redistributions in binary form must reproduce the above copyright notice, this 24 | list of conditions and the following disclaimer in the documentation and/or 25 | other materials provided with the distribution. 26 | 27 | Neither the name of the IPython Development Team nor the names of its 28 | contributors may be used to endorse or promote products derived from this 29 | software without specific prior written permission. 30 | 31 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 32 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 33 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 34 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 35 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 36 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 37 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 38 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 39 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 40 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 41 | 42 | 43 | click License 44 | ============= 45 | 46 | Copyright (c) 2014 by Armin Ronacher. 47 | 48 | Click uses parts of optparse written by Gregory P. Ward and maintained by the 49 | Python software foundation. This is limited to code in the parser.py 50 | module: 51 | 52 | Copyright (c) 2001-2006 Gregory P. Ward. All rights reserved. 53 | Copyright (c) 2002-2006 Python Software Foundation. All rights reserved. 54 | 55 | Some rights reserved. 56 | 57 | Redistribution and use in source and binary forms, with or without 58 | modification, are permitted provided that the following conditions are 59 | met: 60 | 61 | * Redistributions of source code must retain the above copyright 62 | notice, this list of conditions and the following disclaimer. 63 | 64 | * Redistributions in binary form must reproduce the above 65 | copyright notice, this list of conditions and the following 66 | disclaimer in the documentation and/or other materials provided 67 | with the distribution. 68 | 69 | * The names of the contributors may not be used to endorse or 70 | promote products derived from this software without specific 71 | prior written permission. 72 | 73 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 74 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 75 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 76 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 77 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 78 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 79 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 80 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 81 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 82 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 83 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 84 | -------------------------------------------------------------------------------- /src/ped/guess_module.py: -------------------------------------------------------------------------------- 1 | """Helpers for finding possible module matches, given a substring. 2 | 3 | Much of this code is adapted from IPython.core.completerlib 4 | (see NOTICE for license information). 5 | """ 6 | 7 | import difflib 8 | import inspect 9 | import os 10 | import re 11 | import sys 12 | import time 13 | import zipimport 14 | from importlib.machinery import all_suffixes 15 | from typing import Generator, List 16 | 17 | _suffixes = all_suffixes() 18 | 19 | # Regular expression for the python import statement 20 | import_re = re.compile( 21 | r"(?P[a-zA-Z_][a-zA-Z0-9_]*?)" 22 | r"(?P[/\\]__init__)?" 23 | r"(?P{})$".format(r"|".join(re.escape(s) for s in _suffixes)) 24 | ) 25 | 26 | 27 | # Time in seconds after which we give up 28 | TIMEOUT_GIVEUP = 20 29 | 30 | 31 | def guess_module(name: str, **kwargs) -> List: 32 | """Given a string, return a list of probably module paths. 33 | 34 | Example: :: 35 | 36 | guess_module('argparse.Argument') 37 | # ['argparse.ArgumentError', 38 | # 'argparse.ArgumentParser', 39 | # 'argparse.ArgumentTypeError'] 40 | """ 41 | possible = get_possible_modules(name) 42 | return difflib.get_close_matches(name, possible, **kwargs) 43 | 44 | 45 | def get_possible_modules(name: str) -> List[str]: 46 | mod = name.split(".") 47 | if len(mod) < 2: 48 | return get_root_modules() 49 | completion_list = try_import(".".join(mod[:-1]), True) 50 | return [".".join(mod[:-1] + [el]) for el in completion_list] 51 | 52 | 53 | def get_names_by_prefix(prefix: str) -> Generator[str, None, None]: 54 | for name in get_possible_modules(prefix): 55 | if name.startswith(prefix): 56 | yield name 57 | 58 | 59 | def get_root_modules() -> List[str]: 60 | """Return a list containing the names of all the modules available in the 61 | folders of the pythonpath. 62 | """ 63 | rootmodules = list(sys.builtin_module_names) 64 | start_time = time.time() 65 | for path in sys.path: 66 | modules = module_list(path) 67 | try: 68 | modules.remove("__init__") 69 | except ValueError: 70 | pass 71 | if time.time() - start_time > TIMEOUT_GIVEUP: 72 | print("This is taking too long, we give up.\n") 73 | return [] 74 | rootmodules.extend(modules) 75 | rootmodules = list(set(rootmodules)) 76 | return rootmodules 77 | 78 | 79 | def module_list(path) -> List[str]: 80 | """ 81 | Return the list containing the names of the modules available in the given 82 | folder. 83 | """ 84 | # sys.path has the cwd as an empty string, but isdir/listdir need it as '.' 85 | if path == "": 86 | path = "." 87 | 88 | # A few local constants to be used in loops below 89 | pjoin = os.path.join 90 | 91 | if os.path.isdir(path): 92 | # Build a list of all files in the directory and all files 93 | # in its subdirectories. For performance reasons, do not 94 | # recurse more than one level into subdirectories. 95 | files: List[str] = [] 96 | for root, dirs, nondirs in os.walk(path, followlinks=True): 97 | subdir = root[len(path) + 1 :] 98 | if subdir: 99 | files.extend(pjoin(subdir, f) for f in nondirs) 100 | dirs[:] = [] # Do not recurse into additional subdirectories. 101 | else: 102 | files.extend(nondirs) 103 | 104 | else: 105 | try: 106 | files = list(zipimport.zipimporter(path)._files.keys()) # type: ignore 107 | except Exception: 108 | files = [] 109 | 110 | # Build a list of modules which match the import_re regex. 111 | modules = [] 112 | for f in files: 113 | m = import_re.match(f) 114 | if m: 115 | modules.append(m.group("name")) 116 | return list(set(modules)) 117 | 118 | 119 | def try_import(mod: str, only_modules=False) -> List[str]: 120 | try: 121 | m = __import__(mod) 122 | except Exception: 123 | return [] 124 | mods = mod.split(".") 125 | for module in mods[1:]: 126 | m = getattr(m, module) 127 | 128 | m_is_init = hasattr(m, "__file__") and "__init__" in str(m.__file__) 129 | 130 | completions = [] 131 | if (not hasattr(m, "__file__")) or (not only_modules) or m_is_init: 132 | completions.extend( 133 | [attr for attr in dir(m) if is_importable(m, attr, only_modules)] 134 | ) 135 | 136 | completions.extend(getattr(m, "__all__", [])) 137 | if m_is_init and m.__file__: 138 | completions.extend(module_list(os.path.dirname(m.__file__))) 139 | completions_set = set(completions) 140 | if "__init__" in completions: 141 | completions_set.remove("__init__") 142 | return list(completions_set) 143 | 144 | 145 | def is_importable(module, attr, only_modules) -> bool: 146 | if only_modules: 147 | return inspect.ismodule(getattr(module, attr)) 148 | else: 149 | return not (attr[:2] == "__" and attr[-2:] == "__") 150 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | === 2 | ped 3 | === 4 | 5 | .. image:: https://badgen.net/pypi/v/ped 6 | :alt: pypi badge 7 | :target: https://pypi.org/project/ped/ 8 | 9 | .. image:: https://github.com/sloria/ped/actions/workflows/build-release.yml/badge.svg 10 | :alt: build status 11 | :target: https://github.com/sloria/ped/actions/workflows/build-release.yml 12 | 13 | Quickly open Python modules in your text editor. 14 | 15 | .. code-block:: bash 16 | 17 | $ ped django 18 | $ ped django.core.urlresolvers 19 | $ ped django.views.generic.TemplateView 20 | 21 | # Partial name matching 22 | $ ped django.http.resp 23 | Editing django.http.response... 24 | ...Done. 25 | 26 | # Specify which editor to use 27 | $ PED_EDITOR=vim ped django.shortcuts 28 | 29 | 30 | ``ped`` will find your modules in the currently-active virtual environment. 31 | 32 | 33 | Get it now 34 | ********** 35 | 36 | From PyPI: 37 | 38 | :: 39 | 40 | $ pip install ped 41 | 42 | 43 | Or, run it with `pipx `_: 44 | 45 | :: 46 | 47 | $ pipx run ped --help 48 | 49 | 50 | Changing the default editor 51 | *************************** 52 | 53 | ``ped`` will try to use your favorite text editor. If you want to override the editor ``ped`` uses, set the ``PED_EDITOR`` environment variable. 54 | 55 | .. code-block:: bash 56 | 57 | # .zshrc or .bashrc 58 | # Use vim with ped 59 | export PED_EDITOR=vim 60 | 61 | 62 | Opening directories 63 | ******************* 64 | 65 | By default, ``ped`` will open ``__init__.py`` files when a package name is passed. 66 | If you would rather open the package's directory, set the ``PED_OPEN_DIRECTORIES`` environment variable. 67 | 68 | .. code-block:: bash 69 | 70 | # .zshrc or .bashrc 71 | # Open package directories instead of __init__.py 72 | export PED_OPEN_DIRECTORIES=1 73 | 74 | 75 | Tab-completion 76 | ************** 77 | 78 | The ped package contains tab-completion scripts for bash and zsh. Place these files in your system's completion directories. The ``ped.install_completion`` module can be run as a script to output the files to a given location. It determines the correct completion file from 79 | the ``$SHELL`` environment variable. 80 | 81 | Bash completion 82 | --------------- 83 | 84 | To install bash completion, run:: 85 | 86 | # The path given here will depend on your OS 87 | $ python -m ped.install_completion > /usr/local/etc/bash_completion.d 88 | 89 | Zsh completion 90 | --------------- 91 | 92 | To install zsh completion, run:: 93 | 94 | # The path given here will depend on your OS 95 | $ python -m ped.install_completion > /usr/local/share/zsh/site-functions/_ped 96 | 97 | Editor integrations 98 | ******************* 99 | 100 | - `vim-ped `_ 101 | 102 | Kudos 103 | ***** 104 | 105 | This was inspired by `IPython's `_ ``%edit`` magic. 106 | 107 | 108 | Changelog 109 | ********* 110 | 111 | 3.0.0 (2024-01-18) 112 | ------------------ 113 | 114 | - Publish type information. 115 | - Test against Python 3.8-3.12. Older versions of Python are no longer supported. 116 | - *Backwards-incompatible*: Remove ``ped.__version__`` attribute. 117 | Use ``importlib.metadata.version("ped")`` instead. 118 | 119 | 2.1.0 (2020-03-18) 120 | ------------------ 121 | 122 | - Set ``PED_OPEN_DIRECTORIES=1`` to open package directories instead of 123 | opening ``__init__.py`` files. Thanks `Alex Nordin `_. 124 | 125 | 2.0.1 (2018-01-27) 126 | ------------------ 127 | 128 | Bug fixes: 129 | 130 | - Properly handle imports that don't correspond to a file. 131 | 132 | 2.0.0 (2019-01-22) 133 | ------------------ 134 | 135 | - Drop support for Python 2.7 and 3.5. Only Python>=3.6 is supported. 136 | - ``ped`` can be run its own virtual environment separate from the 137 | user's virtual environment. Therefore, ped can be installed with 138 | pipsi or pipx. 139 | - ``install_completion`` script writes to ``stdout`` and detemrmines 140 | script from ``$SHELL``. 141 | 142 | 1.6.0 (2019-01-14) 143 | ------------------ 144 | 145 | - Test against Python 3.7. 146 | 147 | Note: This is the last version to support Python 2. 148 | 149 | 1.5.1 150 | ----- 151 | 152 | - Minor code cleanups. 153 | - Test against Python 2.7, 3.5, and 3.6. Support for older versions is dropped. 154 | 155 | 1.5.0 156 | ----- 157 | 158 | - Support tab-completion in bash and zsh. Thanks `Thomas Kluyver `_. 159 | 160 | 1.4.0 161 | ----- 162 | 163 | - Add ``--info`` argument for outputting name, file path, and line number of modules/functions/classes. 164 | - Fix: Support line numbers in gvim. 165 | 166 | 1.3.0 167 | ----- 168 | 169 | - If a class or function is passed, the editor will open up the file at the correct line number (for supported editors). 170 | 171 | 1.2.1 172 | ----- 173 | 174 | - Fix for Py2 compatibility. 175 | 176 | 1.2.0 177 | ----- 178 | 179 | - Add partial name matching. 180 | 181 | 1.1.0 182 | ----- 183 | 184 | - Add support for editing functions and classes. 185 | 186 | 1.0.2 187 | ----- 188 | 189 | - Fix for editing subpackages, e.g. ``ped pkg.subpkg``. 190 | -------------------------------------------------------------------------------- /tests/test_ped.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import email 3 | import email.mime 4 | import importlib.metadata 5 | from email.mime.message import MIMEMessage 6 | from pathlib import Path 7 | 8 | import pytest 9 | from scripttest import TestFileEnvironment as FileEnvironment 10 | 11 | import ped 12 | from ped.guess_module import guess_module 13 | 14 | 15 | def test_dir_opening(monkeypatch): 16 | monkeypatch.setenv("PED_OPEN_DIRECTORIES", "1") 17 | ped_dir = ped.find_file(ped) 18 | assert Path(ped_dir).is_dir() is True 19 | 20 | 21 | def test_ped_edits_file(mocker): 22 | mocker.patch("ped.edit_file") 23 | ped.ped("pytest") 24 | path = ped.find_file(pytest) 25 | ped.edit_file.assert_called_once_with(path, lineno=0, editor=None) 26 | 27 | 28 | def test_ped_edits_file_with_editor(mocker): 29 | mocker.patch("ped.edit_file") 30 | ped.ped("pytest", editor="nano") 31 | path = ped.find_file(pytest) 32 | ped.edit_file.assert_called_once_with(path, lineno=0, editor="nano") 33 | 34 | 35 | def test_import_obj(): 36 | import argparse 37 | import math 38 | 39 | obj = ped.import_object("argparse") 40 | assert obj is argparse 41 | cls = ped.import_object("argparse.ArgumentParser") 42 | assert cls is argparse.ArgumentParser 43 | func = ped.import_object("math.acos") 44 | assert func is math.acos 45 | 46 | 47 | def test_guess_module(): 48 | assert "argparse" in guess_module("argpar") 49 | assert "argparse.ArgumentParser" in guess_module("argparse.Argu") 50 | assert guess_module("argparse")[0] == "argparse" 51 | 52 | 53 | def test_get_editor_command(): 54 | assert ped.get_editor_command("foo.py", editor="vi") == 'vi "foo.py"' 55 | assert ped.get_editor_command("foo.py", lineno=2, editor="vi") == 'vi +2 "foo.py"' 56 | assert ( 57 | ped.get_editor_command("foo.py", lineno=2, editor="gvim") == 'gvim +2 "foo.py"' 58 | ) 59 | assert ped.get_editor_command("foo.py", lineno=2, editor="kate") == 'kate "foo.py"' 60 | assert ( 61 | ped.get_editor_command("foo.py", lineno=2, editor="emacs") 62 | == 'emacs +2 "foo.py"' 63 | ) 64 | 65 | 66 | def test_get_info(): 67 | name, fpath, lineno = ped.get_info("argparse.ArgumentPars") 68 | assert name == "argparse.ArgumentParser" 69 | assert fpath == ped.find_file(argparse.ArgumentParser) 70 | assert lineno == ped.find_source_lines(argparse.ArgumentParser) 71 | 72 | 73 | # Acceptance tests 74 | 75 | 76 | def assert_in_output(s, res, message=None): 77 | """Assert that a string is in either stdout or std err.""" 78 | assert any([s in res.stdout, s in res.stderr]), message or f"{s} not in output" 79 | 80 | 81 | def assert_not_in_output(s, res, message=None): 82 | """Assert that a string is neither stdout or std err.""" 83 | assert all([s not in res.stdout, s not in res.stderr]), message or f"{s} in output" 84 | 85 | 86 | class TestAcceptance: 87 | @pytest.fixture 88 | def env(self): 89 | return FileEnvironment() 90 | 91 | def test_cli_version(self, env): 92 | res = env.run("ped", "-v", expect_error=True) 93 | ped_version = importlib.metadata.version("ped") 94 | assert_in_output(ped_version + "\n", res) 95 | 96 | def test_info(self, env): 97 | res = env.run("ped", "-i", "email") 98 | name, path, lineno = res.stdout.split() 99 | assert name == "email" 100 | assert path == ped.find_file(email) 101 | assert lineno == str(ped.find_source_lines(email)) 102 | 103 | def test_info_no_lineno(self, env): 104 | res = env.run("ped", "-i", "email.mime") 105 | name, path = res.stdout.split() 106 | assert name == "email.mime" 107 | assert path == ped.find_file(email.mime) 108 | 109 | def test_info_class(self, env): 110 | res = env.run("ped", "-i", "email.mime.message.Mime") 111 | name, path, lineno = res.stdout.split() 112 | assert name == "email.mime.message.MIMEMessage" 113 | assert path == ped.find_file(MIMEMessage) 114 | assert lineno == str(ped.find_source_lines(MIMEMessage)) 115 | 116 | def test_info_not_found(self, env): 117 | res = env.run("ped", "-i", "notfound", expect_error=True) 118 | assert res.returncode == 1 119 | expected = 'ERROR: Could not find module in current environment: "notfound"\n' 120 | assert res.stderr == expected 121 | 122 | def test_complete(self, env): 123 | assert "email" in env.run("ped", "email", "--complete").stdout 124 | assert "email" in env.run("ped", "ema", "--complete").stdout 125 | 126 | res = env.run("ped", "e", "--complete") 127 | assert "email" in res.stdout 128 | assert "errno" in res.stdout 129 | 130 | def test_complete_not_in_help(self, env): 131 | res = env.run("ped", "--help") 132 | assert_not_in_output("--complete", res) 133 | 134 | def test_install_zsh_completion(self, env): 135 | env.environ["SHELL"] = "/usr/bin/zsh" 136 | res = env.run("python", "-m", "ped.install_completion") 137 | assert "#compdef ped" in res.stdout 138 | 139 | def test_install_bash_completion(self, env): 140 | env.environ["SHELL"] = "/usr/bin/bash" 141 | res = env.run("python", "-m", "ped.install_completion") 142 | assert "_complete_ped" in res.stdout 143 | 144 | def test_install_completion_invalid_shell(self, env): 145 | env.environ["SHELL"] = "/usr/bin/badsh" 146 | res = env.run("python", "-m", "ped.install_completion", expect_error=True) 147 | assert "ERROR" in res.stderr 148 | 149 | def test_install_completion_shell_unset(self, env): 150 | try: 151 | del env.environ["SHELL"] 152 | except KeyError: 153 | pass 154 | res = env.run("python", "-m", "ped.install_completion", expect_error=True) 155 | assert "ERROR" in res.stderr 156 | -------------------------------------------------------------------------------- /src/ped/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Open Python modules in your text editor. 3 | 4 | Example: ped django.core.urlresolvers 5 | """ 6 | 7 | import argparse 8 | import importlib 9 | import importlib.metadata 10 | import inspect 11 | import os 12 | import shlex 13 | import subprocess 14 | import sys 15 | from pathlib import Path 16 | from types import ModuleType 17 | from typing import Any, Optional, Tuple 18 | 19 | from .guess_module import get_names_by_prefix, guess_module 20 | from .pypath import patch_sys_path 21 | from .style import GREEN, print_error, sprint, style 22 | 23 | 24 | def main() -> None: 25 | args = parse_args() 26 | if args.complete: 27 | complete(args.module) 28 | else: 29 | # Allow ped to be run in its own virtual environment 30 | # by pre-pending sys.path with the current virtual 31 | # environment's sys.path 32 | patch_sys_path() 33 | try: 34 | ped(module=args.module, editor=args.editor, info=args.info) 35 | except ImportError: 36 | print_error( 37 | f'Could not find module in current environment: "{args.module}"' 38 | ) 39 | sys.exit(1) 40 | 41 | 42 | def parse_args() -> argparse.Namespace: 43 | parser = argparse.ArgumentParser( 44 | description=__doc__, formatter_class=argparse.RawTextHelpFormatter 45 | ) 46 | parser.add_argument("module", help="import path to module, function, or class") 47 | parser.add_argument( 48 | "-e", "--editor", type=str, dest="editor", help="editor program" 49 | ) 50 | parser.add_argument( 51 | "-v", "--version", action="version", version=importlib.metadata.version("ped") 52 | ) 53 | parser.add_argument( 54 | "-i", 55 | "--info", 56 | action="store_true", 57 | help="output name, file path, and line number (if applicable) of module", 58 | ) 59 | parser.add_argument("--complete", action="store_true", help=argparse.SUPPRESS) 60 | return parser.parse_args() 61 | 62 | 63 | def ped(module: str, editor: Optional[str] = None, info: bool = False) -> None: 64 | module_name, fpath, lineno = get_info(module) 65 | if info: 66 | out = f"{module_name} {fpath}" 67 | if lineno is not None: 68 | out += f" {lineno:d}" 69 | print(out) 70 | else: 71 | print(f"Editing {style(module_name, bold=True)}...") 72 | edit_file(fpath, lineno=lineno, editor=editor) 73 | sprint("Done!", fg=GREEN) 74 | 75 | 76 | def complete(ipath: str) -> None: 77 | """Print possible module completions to stdout. 78 | 79 | :param str ipath: Partial import path to a module, function, or class. 80 | """ 81 | for name in get_names_by_prefix(ipath): 82 | print(name) 83 | 84 | 85 | def get_info(ipath: str) -> Tuple[str, str, Optional[int]]: 86 | """Return module name, file path, and line number. 87 | 88 | :param str ipath: Import path to module, function, or class. May be a partial name, 89 | in which case we guess the import path. 90 | """ 91 | module_name = ipath 92 | try: 93 | obj = import_object(module_name) 94 | except ImportError as error: 95 | guessed = guess_module(ipath) 96 | if guessed: 97 | module_name = guessed[0] 98 | obj = import_object(module_name) 99 | else: 100 | raise ImportError( 101 | f'Cannot find any module that matches "{ipath}"' 102 | ) from error 103 | fpath = find_file(obj) 104 | if not fpath: 105 | raise ImportError(f'Cannot find any module that matches "{ipath}"') 106 | lineno = find_source_lines(obj) 107 | return module_name, fpath, lineno 108 | 109 | 110 | def import_object(ipath: str) -> ModuleType: 111 | try: 112 | return importlib.import_module(ipath) 113 | except ImportError as err: 114 | if "." not in ipath: 115 | raise err 116 | module_name, symbol_name = ipath.rsplit(".", 1) 117 | mod = importlib.import_module(module_name) 118 | try: 119 | return getattr(mod, symbol_name) 120 | except AttributeError as error: 121 | raise ImportError( 122 | f'Cannot import "{symbol_name}" from "{module_name}"' 123 | ) from error 124 | raise err 125 | 126 | 127 | # Adapted from IPython.core.oinspect.find_file 128 | def _get_wrapped(obj: Any) -> Any: 129 | """Get the original object if wrapped in one or more @decorators""" 130 | while safe_hasattr(obj, "__wrapped__"): 131 | obj = obj.__wrapped__ 132 | return obj 133 | 134 | 135 | def find_file(obj: Any) -> Optional[str]: 136 | """Find the absolute path to the file where an object was defined. 137 | 138 | This is essentially a robust wrapper around `inspect.getabsfile`. 139 | """ 140 | # get source if obj was decorated with @decorator 141 | obj = _get_wrapped(obj) 142 | 143 | fname = None 144 | try: 145 | fname = inspect.getabsfile(obj) 146 | except TypeError: 147 | # For an instance, the file that matters is where its class was 148 | # declared. 149 | if hasattr(obj, "__class__"): 150 | try: 151 | fname = inspect.getabsfile(obj.__class__) 152 | except TypeError: 153 | # Can happen for builtins 154 | pass 155 | except Exception: 156 | pass 157 | 158 | if fname and os.environ.get("PED_OPEN_DIRECTORIES"): 159 | fname_path = Path(fname) 160 | if fname_path.name == "__init__.py": 161 | # open the directory instead of the __init__.py file. 162 | fname = str(fname_path.parent) 163 | 164 | return fname 165 | 166 | 167 | # Adapted from IPython.core.oinspect.find_source_lines 168 | def find_source_lines(obj: Any) -> Optional[int]: 169 | """Find the line number in a file where an object was defined. 170 | 171 | This is essentially a robust wrapper around `inspect.getsourcelines`. 172 | 173 | Returns None if no file can be found. 174 | """ 175 | obj = _get_wrapped(obj) 176 | 177 | lineno: Optional[int] 178 | try: 179 | try: 180 | lineno = inspect.getsourcelines(obj)[1] 181 | except TypeError: 182 | # For instances, try the class object like getsource() does 183 | if hasattr(obj, "__class__"): 184 | lineno = inspect.getsourcelines(obj.__class__)[1] 185 | else: 186 | lineno = None 187 | except Exception: 188 | return None 189 | 190 | return lineno 191 | 192 | 193 | def safe_hasattr(obj: Any, attr: str) -> bool: 194 | """In recent versions of Python, hasattr() only catches AttributeError. 195 | This catches all errors. 196 | """ 197 | try: 198 | getattr(obj, attr) 199 | return True 200 | except Exception: 201 | return False 202 | 203 | 204 | # Adapted from click._termui_impl 205 | def get_editor() -> str: 206 | for key in "PED_EDITOR", "VISUAL", "EDITOR": 207 | rv = os.environ.get(key) 208 | if rv: 209 | return rv 210 | if sys.platform.startswith("win"): 211 | return "notepad" 212 | for editor in "sensible-editor", "vim", "nano": 213 | if os.system(f"which {editor} >/dev/null 2>&1") == 0: 214 | return editor 215 | return "vi" 216 | 217 | 218 | # Editors that support the +lineno option 219 | SUPPORTS_LINENO = {"vim", "gvim", "vi", "nvim", "mvim", "emacs", "jed", "nano"} 220 | 221 | 222 | def get_editor_command( 223 | filename: str, lineno: Optional[int] = None, editor: Optional[str] = None 224 | ) -> str: 225 | editor = editor or get_editor() 226 | # Enclose in quotes if necessary and legal 227 | if " " in editor and os.path.isfile(editor) and editor[0] != '"': 228 | editor = f'"{editor}"' 229 | if lineno and shlex.split(editor)[0] in SUPPORTS_LINENO: 230 | command = f'{editor} +{lineno:d} "{filename}"' 231 | else: 232 | command = f'{editor} "{filename}"' 233 | return command 234 | 235 | 236 | def edit_file( 237 | filename: str, lineno: Optional[int] = None, editor: Optional[str] = None 238 | ) -> None: 239 | command = get_editor_command(filename, lineno=lineno, editor=editor) 240 | try: 241 | result = subprocess.Popen(command, shell=True) 242 | exit_code = result.wait() 243 | if exit_code != 0: 244 | print_error("Editing failed!") 245 | sys.exit(1) 246 | except OSError as err: 247 | print_error(f"Editing failed: {err}") 248 | sys.exit(1) 249 | 250 | 251 | if __name__ == "__main__": 252 | main() 253 | --------------------------------------------------------------------------------