├── tests ├── __init__.py └── test_configs.py ├── .github ├── FUNDING.yml └── workflows │ ├── tests.yaml │ └── publish.yaml ├── _config.yml ├── screenshot.png ├── pipconf ├── __main__.py ├── consts.py ├── __init__.py ├── templates │ └── pip.conf ├── configs.py └── cli.py ├── LICENSE ├── pyproject.toml ├── .gitignore └── readme.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jjpaulo2 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: pages-themes/hacker@v0.2.0 -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjpaulo2/pipconf/HEAD/screenshot.png -------------------------------------------------------------------------------- /pipconf/__main__.py: -------------------------------------------------------------------------------- 1 | from pipconf.cli import app 2 | 3 | if __name__ == '__main__': 4 | app() 5 | -------------------------------------------------------------------------------- /pipconf/consts.py: -------------------------------------------------------------------------------- 1 | PADDING = ( 2 | 1, 3 | 1, 4 | ) 5 | 6 | PADDING_LIST = ( 7 | 1, 8 | 1, 9 | ) 10 | 11 | 12 | class Chars: 13 | FILLED_CIRCLE = b'\xe2\x97\x8f' 14 | EMPTY_CIRCLE = b'\xe2\x97\x8b' 15 | 16 | 17 | class ExitCodes: 18 | NO_SUCH_FILE_OR_DIRECTORY = 2 19 | FILE_EXISTS = 17 20 | 21 | 22 | class HelpPanels: 23 | DISPLAY = 'Display informations' 24 | CHANGE = 'Change configurations' 25 | -------------------------------------------------------------------------------- /pipconf/__init__.py: -------------------------------------------------------------------------------- 1 | # Module informations 2 | __version__ = '2.0.1' 3 | __index__ = 'https://github.com/jjpaulo2/pipconf' 4 | __license__ = 'BSD-2-Clause' 5 | __author__ = '@jjpaulo2' 6 | 7 | # Cli 8 | __help__ = f""" 9 | [yellow]______ ___________ _____ _____ _ _ ______[/]\n 10 | [yellow]| ___ \_ _| ___ \/ __ \ _ | \ | || ___|[/] 11 | [yellow]| |_/ / | | | |_/ /| / \/ | | | \| || |[/] 12 | [yellow]| __/ | | | __/ | | | | | | . ` || _|[/] 13 | [yellow]| | _| |_| | | \__/\ \_/ / |\ || |[/] 14 | [yellow]\_| \___/\_| \____/\___/\_| \_/\_|[/] v{__version__}\n 15 | Under [bold]{__license__}[/] License, by [bold]{__author__}[/] 16 | Contribute at {__index__} 17 | """ 18 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | python: 15 | 16 | strategy: 17 | matrix: 18 | python_version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 19 | 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 20 22 | 23 | 24 | steps: 25 | - name: Checkout Code 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python_version }} 32 | 33 | - name: Install and configure Poetry 34 | uses: snok/install-poetry@v1 35 | 36 | - name: Install dependencies 37 | run: poetry install --with dev 38 | 39 | - name: Check code issues 40 | run: poetry run task check 41 | 42 | - name: Check security issues 43 | run: poetry run task security 44 | 45 | - name: Unit tests 46 | run: poetry run task tests 47 | 48 | - name: Build the project 49 | run: poetry build 50 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | python: 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/project/pipconf/ 17 | 18 | permissions: 19 | id-token: write 20 | 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 20 23 | 24 | steps: 25 | - name: Checkout Code 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: '3.8' 32 | 33 | - name: Install and configure Poetry 34 | uses: snok/install-poetry@v1 35 | 36 | - name: Install dependencies 37 | run: poetry install --with dev 38 | 39 | - name: Build package 40 | run: poetry build 41 | 42 | - name: Upload build artifacts 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: pipconf-${{ github.ref_name }} 46 | path: dist/* 47 | 48 | - name: Publish package to PyPI 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2021, João Paulo Carvalho 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /pipconf/templates/pip.conf: -------------------------------------------------------------------------------- 1 | [global] 2 | index-url = https://pypi.org/simple 3 | # extra-index-url = 4 | # trusted-host = 5 | # timeout = 15 6 | # retries = 5 7 | # proxy = 8 | # no-cache-dir = false 9 | # disable-pip-version-check = false 10 | # require-virtualenv = false 11 | # no-color = false 12 | # log = 13 | # client-cert = 14 | # cert = 15 | # cache-dir = 16 | # no-python-version-warning = false 17 | # exists-action = 18 | # use-feature = 19 | # use-deprecated = 20 | 21 | [list] 22 | # format = columns 23 | 24 | [install] 25 | # target = 26 | # platform = 27 | # python-version = 28 | # implementation = 29 | # abi = 30 | # root = 31 | # prefix = 32 | # src = src 33 | # no-deps = false 34 | # ignore-installed = false 35 | # ignore-requires-python = false 36 | # no-build-isolation = false 37 | # use-pep517 = true 38 | # install-option = 39 | # global-option = 40 | # compile = true 41 | # no-warn-script-location = false 42 | # no-binary = 43 | # only-binary = 44 | # prefer-binary = false 45 | # require-hashes = false 46 | # progress-bar = on 47 | # upgrade = false 48 | # force-reinstall = false 49 | # no-clean = false 50 | # user = false 51 | # egg = false 52 | 53 | [download] 54 | # dest = 55 | # platform = 56 | # python-version = 57 | # implementation = 58 | # abi = 59 | # no-deps = false 60 | # only-binary = 61 | # prefer-binary = false 62 | 63 | [wheel] 64 | # wheel-dir = 65 | # build-options = 66 | # global-options = 67 | # no-binary = 68 | # only-binary = 69 | # prefer-binary = false 70 | 71 | [search] 72 | index = https://pypi.org/pypi 73 | 74 | [uninstall] 75 | # yes = false 76 | 77 | [freeze] 78 | # requirement = 79 | 80 | [check] 81 | # ignore-installed = false 82 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pipconf" 3 | version = "2.0.1" 4 | description = "Python's PIP configuration manager" 5 | authors = [ "João Paulo Carvalho " ] 6 | repository = "https://github.com/jjpaulo2/pipconf" 7 | documentation = "https://jjpaulo2.github.io/pipconf/" 8 | license = "BSD-2-Clause" 9 | readme = "readme.md" 10 | packages = [{ include = "pipconf" }] 11 | keywords = [ 12 | "configurations", 13 | "configs", 14 | "settings", 15 | "pip", 16 | "pip.conf" 17 | ] 18 | classifiers = [ 19 | "Environment :: Console", 20 | "Topic :: Utilities", 21 | "Typing :: Typed" 22 | ] 23 | 24 | [tool.poetry.urls] 25 | "Bug Tracker" = "https://github.com/jjpaulo2/pipconf/issues" 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.9" 29 | typer = "^0.9.0" 30 | rich = "^13.7.1" 31 | 32 | [tool.poetry.group.dev.dependencies] 33 | mypy = "^1.4.1" 34 | bandit = "^1.7.5" 35 | ruff = "^0.5.6" 36 | pytest = "^7.4.0" 37 | pytest-randomly = "^3.13.0" 38 | pytest-mock = "^3.14.0" 39 | taskipy = "^1.13.0" 40 | 41 | [tool.poetry.scripts] 42 | pipconf = "pipconf.__main__:app" 43 | 44 | [tool.ruff] 45 | line-length = 80 46 | 47 | [tool.ruff.format] 48 | quote-style = "single" 49 | docstring-code-format = true 50 | 51 | [tool.taskipy.tasks] 52 | tests = { cmd = "pytest -vvv", help = "Runs all unit tests" } 53 | lint = { cmd = "ruff format && ruff check --fix", help = "Format and lint the code" } 54 | security = { cmd = "bandit -c pyproject.toml -r pipconf", help = "Check security issues on the code" } 55 | check = { cmd = "ruff format --check && ruff check && mypy .", help = "Check code issues" } 56 | 57 | [build-system] 58 | requires = ["poetry-core"] 59 | build-backend = "poetry.core.masonry.api" 60 | -------------------------------------------------------------------------------- /pipconf/configs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from shutil import copyfile 3 | from typing import List, Optional 4 | 5 | 6 | class PipConfigs: 7 | def __init__(self) -> None: 8 | self._directory: Optional[Path] = None 9 | self._default_file: Optional[Path] = None 10 | self._extension = 'conf' 11 | self._config_file = 'pip.conf' 12 | self._template = Path(__file__).parent.joinpath( 13 | f'templates/{self._config_file}' 14 | ) 15 | 16 | @property 17 | def directory(self) -> Path: 18 | if self._directory is None: 19 | self._directory = Path.home().joinpath('.pip') 20 | if not self._directory.exists(): 21 | self._directory.mkdir(exist_ok=True) 22 | return self._directory 23 | 24 | @property 25 | def default_file(self) -> Path: 26 | if self._default_file is None: 27 | self._default_file = self.directory.joinpath(self._config_file) 28 | return self._default_file 29 | 30 | @property 31 | def current(self) -> Path: 32 | if not self.default_file.exists(): 33 | raise EnvironmentError('No configuration found!') 34 | return self.default_file.readlink() 35 | 36 | @property 37 | def local(self) -> Path: 38 | cwd = Path.cwd() 39 | file_path = cwd.joinpath(self._config_file) 40 | if not file_path.exists(): 41 | raise EnvironmentError(f'No configuration found at {str(cwd)}!') 42 | return file_path 43 | 44 | @property 45 | def available_configs(self) -> List[Path]: 46 | gotten_files = [ 47 | path for path in self.directory.iterdir() if not path.is_symlink() 48 | ] 49 | if not gotten_files: 50 | raise EnvironmentError('No one configuration found!') 51 | return gotten_files 52 | 53 | def get_path(self, name: str) -> Path: 54 | if not name.endswith(self._extension): 55 | name = f'{name}.{self._extension}' 56 | return self.directory.joinpath(name) 57 | 58 | def select(self, path: Path): 59 | if not path.exists(): 60 | raise EnvironmentError(f'The file {path} does not exist!') 61 | if all( 62 | [self.default_file.exists(), not self.default_file.is_symlink()] 63 | ): 64 | backup_path = self.default_file.parent.joinpath( 65 | f'pip.backup.{self._extension}' 66 | ) 67 | copyfile(self.default_file, backup_path) 68 | self.default_file.unlink(missing_ok=True) 69 | self.default_file.symlink_to(path) 70 | 71 | def create(self, path: Path): 72 | if path.exists(): 73 | raise EnvironmentError(f'The file {path} already exists!') 74 | copyfile(self._template, path) 75 | 76 | def show(self, path: Path) -> str: 77 | if not path.exists(): 78 | raise EnvironmentError(f'The file {path} does not exist!') 79 | return path.read_text() 80 | -------------------------------------------------------------------------------- /.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 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .vscode/* 163 | # !.vscode/settings.json 164 | # !.vscode/tasks.json 165 | # !.vscode/launch.json 166 | # !.vscode/extensions.json 167 | # !.vscode/*.code-snippets 168 | 169 | # Local History for Visual Studio Code 170 | .history/ 171 | 172 | # Built Visual Studio Code Extensions 173 | *.vsix 174 | -------------------------------------------------------------------------------- /pipconf/cli.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Optional 2 | from typer import Argument, Typer, Exit, launch 3 | from rich.console import Console 4 | from rich.syntax import Syntax 5 | from rich.panel import Panel 6 | from rich.padding import Padding 7 | from pipconf.configs import PipConfigs 8 | from pipconf.consts import PADDING, PADDING_LIST, ExitCodes, Chars, HelpPanels 9 | from pipconf import __help__ 10 | 11 | 12 | app = Typer(rich_markup_mode='rich', help=__help__) 13 | console = Console() 14 | configs = PipConfigs() 15 | 16 | 17 | @app.command(rich_help_panel=HelpPanels.DISPLAY) 18 | def list(): 19 | """Lists all available configs""" 20 | try: 21 | current = configs.current 22 | except EnvironmentError: 23 | current = None 24 | 25 | try: 26 | lines = [] 27 | for path in configs.available_configs: 28 | if path != current: 29 | lines.append( 30 | f'{Chars.EMPTY_CIRCLE.decode()} {path.name} ([grey42]{str(path)}[/])' 31 | ) 32 | else: 33 | lines.append( 34 | f'[green]{Chars.FILLED_CIRCLE.decode()} {path.name}[/] ([grey42]{str(path)}[/])' 35 | ) 36 | 37 | console.print( 38 | Padding( 39 | f'Available configurations at [yellow]{configs.directory}[/]:', 40 | PADDING, 41 | ) 42 | ) 43 | console.print( 44 | Padding( 45 | '\n'.join(lines), 46 | PADDING_LIST, 47 | ) 48 | ) 49 | 50 | except EnvironmentError as exc: 51 | console.print(Padding(str(exc), PADDING), style='red') 52 | raise Exit(ExitCodes.NO_SUCH_FILE_OR_DIRECTORY) 53 | 54 | 55 | @app.command(rich_help_panel=HelpPanels.DISPLAY) 56 | def current(): 57 | """Shows the currently active config file""" 58 | try: 59 | console.print( 60 | Padding( 61 | f'Current configuration is [yellow]{str(configs.current)}[/]!', 62 | PADDING, 63 | ) 64 | ) 65 | 66 | except EnvironmentError as exc: 67 | console.print(Padding(str(exc), PADDING), style='red') 68 | raise Exit(ExitCodes.NO_SUCH_FILE_OR_DIRECTORY) 69 | 70 | 71 | @app.command(rich_help_panel=HelpPanels.DISPLAY) 72 | def show( 73 | name: Annotated[Optional[str], Argument()] = None, local: bool = False 74 | ): 75 | """Shows a config file content""" 76 | try: 77 | if local: 78 | path = configs.local 79 | else: 80 | path = configs.get_path(name) if name else configs.current 81 | 82 | console.print(Panel(Syntax(configs.show(path), 'ini'), title=str(path))) 83 | 84 | except EnvironmentError as exc: 85 | console.print(Padding(str(exc), PADDING)) 86 | raise Exit(ExitCodes.NO_SUCH_FILE_OR_DIRECTORY) 87 | 88 | 89 | @app.command(rich_help_panel=HelpPanels.CHANGE) 90 | def new(name: str, open: bool = False): 91 | """Creates a new config file""" 92 | try: 93 | path = configs.get_path(name) 94 | configs.create(path) 95 | console.print( 96 | Padding(f'Config file [green]{path.name}[/] created!', PADDING) 97 | ) 98 | 99 | if open: 100 | exit_code = launch(str(path)) 101 | if exit_code >= 0: 102 | launch(str(path), locate=True) 103 | 104 | except EnvironmentError as exc: 105 | console.print(Padding(str(exc), PADDING)) 106 | raise Exit(ExitCodes.FILE_EXISTS) 107 | 108 | 109 | @app.command(rich_help_panel=HelpPanels.CHANGE) 110 | def set(name: str): 111 | """Select a configuration""" 112 | try: 113 | path = configs.get_path(name) 114 | configs.select(path) 115 | console.print( 116 | Padding( 117 | f'Configuration is now set to [yellow]{path.name}[/]!', PADDING 118 | ) 119 | ) 120 | 121 | except EnvironmentError as exc: 122 | console.print(Padding(str(exc), PADDING)) 123 | raise Exit(ExitCodes.NO_SUCH_FILE_OR_DIRECTORY) 124 | 125 | 126 | @app.command(rich_help_panel=HelpPanels.CHANGE) 127 | def local(): 128 | """Select a config file in current workdir""" 129 | try: 130 | local_config = configs.local 131 | configs.select(local_config) 132 | console.print( 133 | Padding( 134 | f'Configuration is now set to [yellow]{str(local_config)}[/]!', 135 | PADDING, 136 | ) 137 | ) 138 | 139 | except EnvironmentError as exc: 140 | console.print(Padding(str(exc), PADDING)) 141 | raise Exit(ExitCodes.NO_SUCH_FILE_OR_DIRECTORY) 142 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PIPCONF - The PIP configuration manager 2 | 3 | ![Python](https://img.shields.io/badge/Python-3.8_%7C_3.9_%7C_3.10_%7C_3.11_%7C_3.12-green) 4 | [![Tests](https://github.com/jjpaulo2/pipconf/actions/workflows/tests.yaml/badge.svg?branch=main)](https://github.com/jjpaulo2/pipconf/actions/workflows/tests.yaml) 5 | [![Publish](https://github.com/jjpaulo2/pipconf/actions/workflows/publish.yaml/badge.svg)](https://github.com/jjpaulo2/pipconf/actions/workflows/publish.yaml) 6 | [![PyPI - Version](https://img.shields.io/pypi/v/pipconf)](https://pypi.org/project/pipconf/) 7 | [![Sponsor](https://img.shields.io/badge/Sponsor-Pipconf-deeppink)](https://github.com/sponsors/jjpaulo2) 8 | 9 | ![](screenshot.png) 10 | 11 | If you need to manage multiple configurations containing indexes and trusted hosts for PIP, this project was made for you. 12 | 13 | - [Introduction](#introduction) 14 | - [Installation](#instalation) 15 | - [Usage](#usage) 16 | - [List all available configurations](#list-all-available-configurations) 17 | - [Create a new configuration](#create-a-new-configuration) 18 | - [Get current configuration](#get-current-configuration) 19 | - [Show the configuration file content](#show-the-configuration-file-content) 20 | - [Set configuration file](#set-configuration-file) 21 | - [Set local file as the current config](#set-local-file-as-the-current-config) 22 | 23 | 24 | 25 | ### Introduction 26 | 27 | The `pipconf` is based in `pip.conf` files in `$HOME/.pip` folder. But you won't create it with this name. So, you need to create your configuration files following the template `config-file-name.conf`. 28 | 29 | For the first steps, create a new configuration. 30 | 31 | ```shell 32 | $ pipconf new my-config.conf --open 33 | ``` 34 | 35 | The file will come with a configuration template. All you need to do is just replace with your necessities. 36 | 37 | ```toml 38 | 39 | 40 | [global] 41 | index-url = https://pypi.org/simple 42 | # extra-index-url = 43 | # trusted-host = 44 | # timeout = 15 45 | # retries = 5 46 | ... 47 | ``` 48 | 49 | ```toml 50 | 51 | 52 | [global] 53 | index-url = http://mycompany.com/artifactory/api/pypi/pypi/simple 54 | extra-index-url = http://mycompany.com/artifactory/api/pypi/pypi-local/simple/ 55 | trusted-host = mycompany.com 56 | ``` 57 | 58 | ## Instalation 59 | 60 | The package is available at [pypi.org](https://pypi.org/project/pipconf/). Then, you can install it using pip. 61 | 62 | ```shell 63 | $ pip install pipconf 64 | ``` 65 | 66 | ## Usage 67 | 68 | ```shell 69 | $ pipconf --help 70 | ``` 71 | The expected output should be something like the following content. 72 | 73 | ``` 74 | Usage: pipconf [OPTIONS] COMMAND [ARGS]... 75 | 76 | ______ ___________ _____ _____ _ _ ______ 77 | | ___ \_ _| ___ \/ __ \ _ | \ | || ___| 78 | | |_/ / | | | |_/ /| / \/ | | | \| || | 79 | | __/ | | | __/ | | | | | | . ` || _| 80 | | | _| |_| | | \__/\ \_/ / |\ || | 81 | \_| \___/\_| \____/\___/\_| \_/\_| v2.0.0 82 | 83 | Under BSD-2-Clause License, by @jjpaulo2 84 | Contribute at https://github.com/jjpaulo2/pipconf 85 | 86 | ╭─ Display informations ───────────────────────────╮ 87 | │ current Shows the currently active config file │ 88 | │ list Lists all available configs │ 89 | │ show Shows a config file content │ 90 | ╰──────────────────────────────────────────────────╯ 91 | ╭─ Change configurations ──────────────────────────╮ 92 | │ local Select a config file in current workdir │ 93 | │ new Creates a new config file │ 94 | │ set Select a configuration │ 95 | ╰──────────────────────────────────────────────────╯ 96 | ``` 97 | 98 | ### List all available configurations 99 | 100 | ```shell 101 | $ pipconf list 102 | ``` 103 | ``` 104 | Available configurations at /home/user/.pip: 105 | 106 | ● company.conf (/home/user/.pip/company.conf) 107 | ○ my-config.conf (/home/user/.pip/my-config.conf) 108 | ○ project.conf (/home/user/.pip/project-config.conf) 109 | ``` 110 | 111 | ### Create a new configuration 112 | 113 | ```shell 114 | $ pipconf new my-config 115 | ``` 116 | ``` 117 | Config file my-config.conf created! 118 | ``` 119 | 120 | You can also pass a `--open` flag to create and open the file. 121 | 122 | ```shell 123 | $ pipconf new my-config --open 124 | ``` 125 | 126 | ### Get current configuration 127 | 128 | ```shell 129 | $ pipconf current 130 | ``` 131 | ``` 132 | Current configuration is /home/user/.pip/my-conf.conf! 133 | ``` 134 | 135 | ### Show the configuration file content 136 | 137 | ```shell 138 | $ pipconf show my-conf 139 | ``` 140 | ``` 141 | ╭─────────── /home/user/.pip/test.conf ────────────╮ 142 | │ [global] │ 143 | │ index-url = https://pypi.org/simple │ 144 | │ ... │ 145 | ╰──────────────────────────────────────────────────╯ 146 | ``` 147 | 148 | If you don't pass any parameter, the command will show the content of the current configation file. 149 | 150 | ```shell 151 | $ pipconf show 152 | ``` 153 | 154 | ### Set configuration file 155 | 156 | ```shell 157 | $ pipconf set my-config 158 | ``` 159 | ``` 160 | Configuration is now set to my-config.conf! 161 | ``` 162 | 163 | ### Set local file as the current config 164 | 165 | If in the current workdir exists a file named `pip.conf`, you can set it as the current configuration. 166 | 167 | ```shell 168 | $ pipconf local 169 | ``` 170 | ``` 171 | Configuration is now set to /home/user/workspace/project/pip.conf! 172 | ``` 173 | 174 | --- 175 | 176 | Under [BSD-2-Clause License](./LICENSE), by [@jjpaulo2](https://github.com/jjpaulo2). 177 | -------------------------------------------------------------------------------- /tests/test_configs.py: -------------------------------------------------------------------------------- 1 | from pipconf.configs import PipConfigs 2 | from unittest.mock import MagicMock, patch 3 | from pytest import mark, raises 4 | from pathlib import Path 5 | 6 | 7 | HOME_DIRECTORY = '/home/user' 8 | SOME_PATH = Path('/some/path') 9 | SYMLINK_PATH = Path('/home/user/.pip/test.conf') 10 | HOME_MOCK = MagicMock(return_value=Path(HOME_DIRECTORY)) 11 | 12 | 13 | @patch('pipconf.configs.Path.home', HOME_MOCK) 14 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 15 | def test_directory_exists(): 16 | configs = PipConfigs() 17 | expected_value = Path(HOME_DIRECTORY).joinpath('.pip') 18 | assert configs.directory == expected_value 19 | 20 | 21 | @patch('pipconf.configs.Path.home', HOME_MOCK) 22 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=False)) 23 | @patch('pipconf.configs.Path.mkdir') 24 | def test_directory_does_not_exists(mkdir: MagicMock): 25 | configs = PipConfigs() 26 | expected_value = Path(HOME_DIRECTORY).joinpath('.pip') 27 | assert configs.directory == expected_value 28 | assert mkdir.call_count == 1 29 | 30 | 31 | @patch('pipconf.configs.Path.home', HOME_MOCK) 32 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 33 | def test_default_file(): 34 | configs = PipConfigs() 35 | expected_value = Path(HOME_DIRECTORY).joinpath('.pip/pip.conf') 36 | assert configs.default_file == expected_value 37 | 38 | 39 | @patch('pipconf.configs.Path.home', HOME_MOCK) 40 | @patch('pipconf.configs.Path.exists', MagicMock(side_effect=[True, False])) 41 | def test_current_file_not_found(): 42 | with raises(EnvironmentError): 43 | PipConfigs().current 44 | 45 | 46 | @patch('pipconf.configs.Path.home', HOME_MOCK) 47 | @patch('pipconf.configs.Path.exists', MagicMock(side_effect=[True, True])) 48 | @patch('pipconf.configs.Path.readlink', MagicMock(return_value=SYMLINK_PATH)) 49 | def test_current(): 50 | configs = PipConfigs() 51 | assert configs.current == SYMLINK_PATH 52 | 53 | 54 | @patch('pipconf.configs.Path.cwd', MagicMock(return_value=SOME_PATH)) 55 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=False)) 56 | def test_local_not_found(): 57 | with raises(EnvironmentError): 58 | PipConfigs().current 59 | 60 | 61 | @patch('pipconf.configs.Path.cwd', MagicMock(return_value=SOME_PATH)) 62 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 63 | def test_local(): 64 | config = PipConfigs() 65 | assert config.local == SOME_PATH.joinpath('pip.conf') 66 | 67 | 68 | @patch('pipconf.configs.Path.home', HOME_MOCK) 69 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 70 | @patch('pipconf.configs.Path.iterdir', MagicMock(return_value=[])) 71 | def test_available_configs_empty(): 72 | with raises(EnvironmentError): 73 | PipConfigs().available_configs 74 | 75 | 76 | @patch('pipconf.configs.Path.home', HOME_MOCK) 77 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 78 | @patch('pipconf.configs.Path.iterdir', MagicMock(return_value=[SOME_PATH])) 79 | @patch('pipconf.configs.Path.is_symlink', MagicMock(return_value=True)) 80 | def test_available_configs_empty_cause_of_symlinks(): 81 | with raises(EnvironmentError): 82 | PipConfigs().available_configs 83 | 84 | 85 | @patch('pipconf.configs.Path.home', HOME_MOCK) 86 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 87 | @patch('pipconf.configs.Path.iterdir', MagicMock(return_value=[SOME_PATH])) 88 | @patch('pipconf.configs.Path.is_symlink', MagicMock(return_value=False)) 89 | def test_available_configs(): 90 | configs = PipConfigs() 91 | assert configs.available_configs == [SOME_PATH] 92 | 93 | 94 | @mark.parametrize( 95 | ('input_name', 'expected_return'), 96 | [ 97 | ('file', Path(HOME_DIRECTORY, '.pip/file.conf')), 98 | ('file.conf', Path(HOME_DIRECTORY, '.pip/file.conf')), 99 | ], 100 | ) 101 | @patch('pipconf.configs.Path.home', HOME_MOCK) 102 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 103 | def test_get_path(input_name: str, expected_return: Path): 104 | configs = PipConfigs() 105 | assert configs.get_path(input_name) == expected_return 106 | 107 | 108 | @patch('pipconf.configs.Path.home', HOME_MOCK) 109 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=False)) 110 | def test_select_path_does_not_exists(): 111 | with raises(EnvironmentError): 112 | PipConfigs().select(SOME_PATH) 113 | 114 | 115 | @patch('pipconf.configs.Path.home', HOME_MOCK) 116 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 117 | @patch('pipconf.configs.Path.is_symlink', MagicMock(return_value=True)) 118 | @patch('pipconf.configs.Path.unlink') 119 | @patch('pipconf.configs.Path.symlink_to') 120 | def test_select(symlink_to_mock: MagicMock, unlink_mock: MagicMock): 121 | PipConfigs().select(SOME_PATH) 122 | assert symlink_to_mock.call_count == 1 123 | assert unlink_mock.call_count == 1 124 | 125 | 126 | @patch('pipconf.configs.Path.home', HOME_MOCK) 127 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 128 | @patch('pipconf.configs.Path.is_symlink', MagicMock(return_value=False)) 129 | @patch('pipconf.configs.Path.unlink') 130 | @patch('pipconf.configs.Path.symlink_to') 131 | @patch('pipconf.configs.copyfile') 132 | def test_select_backup_original_conf( 133 | copyfile_mock: MagicMock, symlink_to_mock: MagicMock, unlink_mock: MagicMock 134 | ): 135 | PipConfigs().select(SOME_PATH) 136 | assert copyfile_mock.call_count == 1 137 | assert symlink_to_mock.call_count == 1 138 | assert unlink_mock.call_count == 1 139 | 140 | 141 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 142 | def test_create_path_does_not_exists(): 143 | with raises(EnvironmentError): 144 | PipConfigs().create(SOME_PATH) 145 | 146 | 147 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=False)) 148 | @patch('pipconf.configs.copyfile') 149 | def test_create(copyfile_mock: MagicMock): 150 | PipConfigs().create(SOME_PATH) 151 | assert copyfile_mock.call_count == 1 152 | 153 | 154 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=False)) 155 | def test_show_path_does_not_exists(): 156 | with raises(EnvironmentError): 157 | PipConfigs().show(SOME_PATH) 158 | 159 | 160 | @patch('pipconf.configs.Path.exists', MagicMock(return_value=True)) 161 | @patch('pipconf.configs.Path.read_text') 162 | def test_show(read_text_mock: MagicMock): 163 | read_text_mock.return_value = 'some content' 164 | config = PipConfigs() 165 | assert config.show(SOME_PATH) == 'some content' 166 | --------------------------------------------------------------------------------