├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── setup.cfg ├── setup.py ├── tests ├── .netrc ├── __init__.py └── test_tinynetrc.py ├── tinynetrc.py └── tox.ini /.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 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 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 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | .static_storage/ 59 | .media/ 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | README.html 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | cache: pip 4 | install: travis_retry pip install -U tox 5 | script: tox 6 | jobs: 7 | fast_finish: true 8 | include: 9 | - { python: '3.8', env: TOXENV=lint } 10 | - { python: '2.7', env: TOXENV=py27 } 11 | - { python: '3.5', env: TOXENV=py35 } 12 | - { python: '3.6', env: TOXENV=py36 } 13 | - { python: '3.7', env: TOXENV=py37 } 14 | - { python: '3.8', env: TOXENV=py38 } 15 | - { python: '3.9-dev', env: TOXENV=py39 } 16 | 17 | - stage: PyPI Release 18 | if: tag IS present 19 | python: "3.8" 20 | env: [] 21 | install: skip 22 | script: skip 23 | deploy: 24 | provider: pypi 25 | user: sloria 26 | on: 27 | tags: true 28 | distributions: sdist bdist_wheel 29 | password: 30 | secure: oYZ97xz/ZhylohjrvClpFOURvlXCxFig4KMfAg0cuuL1TLNXalAnYnWebZN9srVmBDZ324aAHt9VZnCtZCXORhkMTDsBTnOrWkb0jsnxFN2PFP9KARLltiVknvTZb6Uy70XsMlKW920CWUKI+voAI9agbUETaxtc212jo3yHkxYQGtfTfURfQCZhYYoLK45v04Rm/HSQHGgUlg8xOJm/uJTC2xpPOWJv3XkoXfS18p0g0IsrVbtNdAvJ+IOJbRig2Qoq0vMXiNMArJPDTP1scjME5qKexN+5UpVzsUfm6254PK0d/Ap2DMOcZVPlxXIIMHoN5gpwOSN3SAtfbb5NvJbtNRjB12erzw+0DtB8ibJ3DxrOMJAihXkd8REIcqOU3Hsmh+1PKUkYfHjbk0SUb0AupllxgiP1Bp5fQBwPEp+he9atJAkAY9hr7EK0JqQ12DrtUgQ9kqDbz26Ad0II4R9M7xfJdPRljS9QY9RjTemnlcM+4rya7PzmblYqAYjedfb5zSAdyTEY9Px/e8pscKdpisLPGHpnwFCoAlaGtq5NzmzcqaoRImkl6B3YloapuCTs8RVwP0Me42qXSuGr+ocZTumxvt9a8bwoMOxyZX6Il65JmwaiR2BLY8cRjl9AQKhWcAyjNXYCdn454xO3QMtFhqm3+1zmEdHcltbdCVY= 31 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.3.1 (2021-08-15) 5 | __________________ 6 | 7 | * Address ``DeprecationWarning`` re: ``collections.abc`` on Python 3.9 8 | (`#45 `_). 9 | Thanks `@tirkarthi `_ for the PR. 10 | 11 | 1.3.0 (2018-12-11) 12 | ------------------ 13 | 14 | * Add context manager API (`#4 `_). 15 | * Drop support for Python 3.4. 16 | * Test against Python 3.7. 17 | 18 | 1.2.0 (2018-01-12) 19 | ------------------ 20 | 21 | * Don't error if ``$HOME`` is not set (`#2 `_). 22 | 23 | 1.1.0 (2017-11-04) 24 | ------------------ 25 | 26 | * Add dict-like shorthand syntax. 27 | 28 | 1.0.0 (2017-11-03) 29 | ------------------ 30 | 31 | * Initial release. 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include *.rst 3 | recursive-include tests * 4 | recursive-exclude tests *.pyc 5 | recursive-exclude tests *.pyo 6 | recursive-exclude examples *.pyc 7 | recursive-exclude examples *.pyo 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | tinynetrc 3 | ********* 4 | 5 | .. image:: https://badgen.net/pypi/v/tinynetrc 6 | :alt: pypi badge 7 | :target: https://pypi.org/project/tinynetrc/ 8 | 9 | .. image:: https://badgen.net/travis/sloria/tinynetrc/master 10 | :alt: travis-ci status 11 | :target: https://travis-ci.org/sloria/tinynetrc 12 | 13 | Read and write .netrc files in Python. 14 | 15 | 16 | ``tinynetrc`` uses the `netrc `_ 17 | module from the standard library under the hood and adds a few 18 | improvements: 19 | 20 | * Adds write functionality. 21 | * Fixes a std lib `bug `_ with 22 | formatting a .netrc file.* 23 | * Parses .netrc into dictionary values rather than tuples. 24 | 25 | \*This bug is fixed in newer versions of Python. 26 | 27 | Get it now 28 | ========== 29 | :: 30 | 31 | pip install tinynetrc 32 | 33 | 34 | ``tinynetrc`` supports Python >= 2.7 or >= 3.5. 35 | 36 | Usage 37 | ===== 38 | 39 | .. code-block:: python 40 | 41 | from tinynetrc import Netrc 42 | 43 | netrc = Netrc() # parse ~/.netrc 44 | # Get credentials 45 | netrc['api.heroku.com']['login'] 46 | netrc['api.heroku.com']['password'] 47 | 48 | # Modify an existing entry 49 | netrc['api.heroku.com']['password'] = 'newpassword' 50 | netrc.save() # writes to ~/.netrc 51 | 52 | # Add a new entry 53 | netrc['surge.surge.sh'] = { 54 | 'login': 'sloria1@gmail.com', 55 | 'password': 'secret' 56 | } 57 | netrc.save() 58 | 59 | # Removing an new entry 60 | del netrc['surge.surge.sh'] 61 | netrc.save() 62 | 63 | 64 | You can also use ``Netrc`` as a context manager, which will automatically save 65 | ``~/.netrc``. 66 | 67 | .. code-block:: python 68 | 69 | from tinynetrc import Netrc 70 | with Netrc() as netrc: 71 | netrc['api.heroku.com']['password'] = 'newpassword' 72 | assert netrc.is_dirty is True 73 | # saved! 74 | 75 | License 76 | ======= 77 | 78 | MIT licensed. See the bundled `LICENSE `_ file for more details. 79 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | ignore = E127,E128,E265,E302,N803,N804,N806,E266,E731 6 | max-line-length = 100 7 | exclude = .git,.ropeproject,.tox,docs,.git,examples/,build,setup.py,env,venv 8 | 9 | [tool:pytest] 10 | norecursedirs = .git .ropeproject .tox docs env venv 11 | addopts = -v --tb=short 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | from setuptools import setup 4 | 5 | EXTRAS_REQUIRE = { 6 | 'tests': ['pytest'], 7 | 'lint': [ 8 | 'flake8==3.9.2', 9 | ], 10 | } 11 | EXTRAS_REQUIRE['dev'] = ( 12 | EXTRAS_REQUIRE['tests'] + EXTRAS_REQUIRE['lint'] + ['tox', 'konch'] 13 | ) 14 | 15 | 16 | def find_version(fname): 17 | """Attempts to find the version number in the file names fname. 18 | Raises RuntimeError if not found. 19 | """ 20 | version = '' 21 | with open(fname, 'r') as fp: 22 | reg = re.compile(r'__version__ = [\'"]([^\'"]*)[\'"]') 23 | for line in fp: 24 | m = reg.match(line) 25 | if m: 26 | version = m.group(1) 27 | break 28 | if not version: 29 | raise RuntimeError('Cannot find version information') 30 | return version 31 | 32 | 33 | def read(fname): 34 | with open(fname) as fp: 35 | content = fp.read() 36 | return content 37 | 38 | 39 | setup( 40 | name='tinynetrc', 41 | version=find_version('tinynetrc.py'), 42 | description='Read and write .netrc files.', 43 | long_description=read('README.rst'), 44 | author='Steven Loria', 45 | author_email='sloria1@gmail.com', 46 | url='https://github.com/sloria/tinynetrc', 47 | install_requires=[], 48 | extras_require=EXTRAS_REQUIRE, 49 | license='MIT', 50 | zip_safe=False, 51 | keywords='netrc posix', 52 | classifiers=[ 53 | 'Intended Audience :: Developers', 54 | 'License :: OSI Approved :: MIT License', 55 | 'Programming Language :: Python :: 2', 56 | 'Programming Language :: Python :: 2.7', 57 | 'Programming Language :: Python :: 3', 58 | 'Programming Language :: Python :: 3.5', 59 | 'Programming Language :: Python :: 3.6', 60 | 'Programming Language :: Python :: 3.7', 61 | 'Programming Language :: Python :: Implementation :: CPython', 62 | ], 63 | py_modules=['tinynetrc'], 64 | ) 65 | -------------------------------------------------------------------------------- /tests/.netrc: -------------------------------------------------------------------------------- 1 | machine mail.google.com 2 | login joe@gmail.com 3 | account justagmail 4 | password somethingSecret 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sloria/tinynetrc/e4e61448c0a80d99d75b85e57d6584c3a702d72b/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_tinynetrc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tinynetrc 3 | import os 4 | 5 | HERE = os.path.dirname(os.path.abspath(__file__)) 6 | 7 | 8 | @pytest.fixture() 9 | def netrc(): 10 | return tinynetrc.Netrc(os.path.join(HERE, '.netrc')) 11 | 12 | 13 | def test_file_not_found(): 14 | with pytest.raises(IOError): 15 | tinynetrc.Netrc('notfound') 16 | 17 | 18 | def test_home_unset(monkeypatch): 19 | # Make "~" == the current directory 20 | monkeypatch.setattr(os.path, 'expanduser', lambda path: HERE) 21 | # Unset $HOME 22 | monkeypatch.delenv('HOME', raising=False) 23 | # No error 24 | result = tinynetrc.Netrc() 25 | assert 'mail.google.com' in result.hosts 26 | 27 | 28 | def test_hosts(netrc): 29 | assert 'mail.google.com' in netrc.hosts 30 | assert isinstance(netrc.hosts['mail.google.com'], tuple) 31 | 32 | 33 | def test_machines(netrc): 34 | assert 'mail.google.com' in netrc.machines 35 | assert isinstance(netrc.machines['mail.google.com'], dict) 36 | assert netrc.machines['mail.google.com']['login'] == 'joe@gmail.com' 37 | assert netrc.machines['mail.google.com']['account'] == 'justagmail' 38 | assert netrc.machines['mail.google.com']['password'] == 'somethingSecret' 39 | 40 | 41 | def test_dict_like_behavior(netrc): 42 | assert 'mail.google.com' in netrc 43 | assert netrc['mail.google.com']['login'] == 'joe@gmail.com' 44 | assert len(netrc) == len(netrc.machines) 45 | assert list(netrc) == list(netrc.machines) 46 | del netrc['mail.google.com'] 47 | assert 'mail.google.com' not in netrc.machines 48 | 49 | 50 | def test_machine_formatting(netrc): 51 | assert 'login joe@gmail.com' in netrc.format() 52 | 53 | 54 | def test_machines_can_be_modified(netrc): 55 | netrc.machines['mail.google.com']['password'] = 'newpassword' 56 | assert 'newpassword' in netrc.format() 57 | 58 | 59 | def test_machines_can_be_added(netrc): 60 | netrc.machines['api.heroku.com']['login'] = 'joe@test.test' 61 | netrc.machines['api.heroku.com']['password'] = 'supersecret' 62 | assert 'api.heroku.com' in netrc.format() 63 | assert 'supersecret' in netrc.format() 64 | 65 | 66 | def test_machines_can_be_removed(netrc): 67 | assert 'mail.google.com' in netrc.format() 68 | del netrc.machines['mail.google.com'] 69 | assert 'mail.google.com' not in netrc.format() 70 | 71 | def test_is_dirty_after_addition(netrc): 72 | assert netrc.is_dirty is False 73 | netrc.machines['api.heroku.com']['login'] = 'joe@test.test' 74 | assert netrc.is_dirty is True 75 | 76 | def test_is_dirty_after_deletion(netrc): 77 | assert netrc.is_dirty is False 78 | del netrc['mail.google.com'] 79 | assert netrc.is_dirty is True 80 | -------------------------------------------------------------------------------- /tinynetrc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Read and write .netrc files.""" 3 | import netrc 4 | import os 5 | from collections import defaultdict 6 | 7 | try: 8 | from collections.abc import MutableMapping 9 | except ImportError: 10 | from collections import MutableMapping 11 | 12 | __version__ = '1.3.1' 13 | 14 | 15 | def dictify_hosts(hosts): 16 | ret = defaultdict(lambda: { 17 | 'login': None, 18 | 'account': None, 19 | 'password': None, 20 | }) 21 | for machine, info in hosts.items(): 22 | ret[machine] = { 23 | 'login': info[0], 24 | 'account': info[1], 25 | 'password': info[2], 26 | } 27 | return ret 28 | 29 | 30 | def dedictify_machines(machines): 31 | return { 32 | machine: (info.get('login'), info.get('account'), info.get('password')) 33 | for machine, info 34 | in machines.items() 35 | } 36 | 37 | 38 | class Netrc(MutableMapping): 39 | 40 | def __init__(self, file=None): 41 | if file is None: 42 | file = os.path.join(os.path.expanduser('~'), '.netrc') 43 | self.file = file 44 | self._netrc = netrc.netrc(file) 45 | self.machines = dictify_hosts(self._netrc.hosts) 46 | 47 | def authenticators(self, host): 48 | return self._netrc.authenticators(host) 49 | 50 | @property 51 | def hosts(self): 52 | return self._netrc.hosts 53 | 54 | ##### dict-like interface implementation ##### 55 | 56 | def __getitem__(self, key): 57 | return self.machines[key] 58 | 59 | def __setitem__(self, key, value): 60 | self.machines[key] = value 61 | 62 | def __delitem__(self, key): 63 | del self.machines[key] 64 | 65 | def __iter__(self): 66 | return iter(self.machines) 67 | 68 | def __len__(self): 69 | return len(self.machines) 70 | 71 | #### end dict-like interface implementation ##### 72 | 73 | def __enter__(self): 74 | return self 75 | 76 | def __exit__(self, exc_type, exc_value, exc_traceback): 77 | if not exc_type and self.is_dirty: 78 | self.save() 79 | 80 | def __repr__(self): 81 | return repr(dict(self.machines)) 82 | 83 | @property 84 | def is_dirty(self): 85 | return self.machines != dictify_hosts(self._netrc.hosts) 86 | 87 | # Adapted from https://github.com/python/cpython/blob/master/Lib/netrc.py 88 | # to support Python 2 89 | def format(self): 90 | """Dump the class data in the format of a .netrc file.""" 91 | self._netrc.hosts = dedictify_machines(self.machines) 92 | rep = "" 93 | for host in self._netrc.hosts.keys(): 94 | attrs = self._netrc.hosts[host] 95 | rep += "machine {host}\n\tlogin {attrs[0]}\n".format(host=host, 96 | attrs=attrs) 97 | if attrs[1]: 98 | rep += "\taccount {attrs[1]}\n".format(attrs=attrs) 99 | rep += "\tpassword {attrs[2]}\n".format(attrs=attrs) 100 | for macro in self._netrc.macros.keys(): 101 | rep += "macdef {macro}\n".format(macro=macro) 102 | for line in self._netrc.macros[macro]: 103 | rep += line 104 | rep += "\n" 105 | return rep 106 | 107 | def save(self): 108 | with open(self.file, 'w') as fp: 109 | fp.write(self.format()) 110 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,py27,py35,py36,py37,py38,py39 3 | 4 | [testenv] 5 | extras = tests 6 | commands = pytest {posargs} 7 | 8 | [testenv:lint] 9 | extras = lint 10 | commands = flake8 {posargs} 11 | 12 | ; Below tasks are for development only (not run in CI) 13 | 14 | [testenv:watch-readme] 15 | deps = restview 16 | skip_install = true 17 | commands = restview README.rst 18 | --------------------------------------------------------------------------------