diff --git a/Doc/library/netrc.rst b/Doc/library/netrc.rst index 74c97e8c9a9759..47acc9faee503a 100644 --- a/Doc/library/netrc.rst +++ b/Doc/library/netrc.rst @@ -18,9 +18,10 @@ the Unix :program:`ftp` program and other FTP clients. .. class:: netrc([file]) A :class:`~netrc.netrc` instance or subclass instance encapsulates data from a netrc - file. The initialization argument, if present, specifies the file to parse. If - no argument is given, the file :file:`.netrc` in the user's home directory -- - as determined by :func:`os.path.expanduser` -- will be read. Otherwise, + file. The initialization argument, if present, specifies the file to parse. If no + argument is given, it will look for the file path in the :envvar:`!NETRC` environment variable, + before defaulting to reading the file :file:`.netrc` in the user's home + directory -- as determined by :func:`os.path.expanduser`. If the file cannot be found, a :exc:`FileNotFoundError` exception will be raised. Parse errors will raise :exc:`NetrcParseError` with diagnostic information including the file name, line number, and terminating token. @@ -47,6 +48,9 @@ the Unix :program:`ftp` program and other FTP clients. can contain arbitrary characters, like whitespace and non-ASCII characters. If the login name is anonymous, it won't trigger the security check. + .. versionadded:: next + Added support for the :envvar:`!NETRC` environment variable. + .. exception:: NetrcParseError diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 9f327cf904da1b..0071c30c991f71 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -116,6 +116,13 @@ math (Contributed by Sergey B Kirpichev in :gh:`132908`.) +netrc +----- + +* Added support for the :envvar:`!NETRC` environment variable in :func:`netrc.netrc`. + (Contributed by Berthin Torres in :gh:`135788`.) + + os.path ------- diff --git a/Lib/netrc.py b/Lib/netrc.py index bd003e80a48081..92bd3cde4a4bbd 100644 --- a/Lib/netrc.py +++ b/Lib/netrc.py @@ -78,9 +78,10 @@ def push_token(self, token): class netrc: def __init__(self, file=None): - default_netrc = file is None - if file is None: - file = os.path.join(os.path.expanduser("~"), ".netrc") + envvar_netrc = os.environ.get("NETRC", "") + home_netrc = os.path.join(os.path.expanduser("~"), ".netrc") + file = file or envvar_netrc or home_netrc + default_netrc = (file == home_netrc) self.hosts = {} self.macros = {} try: diff --git a/Lib/test/test_netrc.py b/Lib/test/test_netrc.py index 9d720f627102e3..e7e9359e11644e 100644 --- a/Lib/test/test_netrc.py +++ b/Lib/test/test_netrc.py @@ -1,41 +1,119 @@ import netrc, os, unittest, sys, textwrap +from contextlib import ExitStack from test import support -from test.support import os_helper +from test.support import os_helper, subTests +from unittest import mock + + +class NetrcEnvironment: + """Context manager for setting up an isolated environment to test + `.netrc` file handling. + + This class configures a temporary directory for the `.netrc` file and + environment variables, providing a controlled setup to simulate different + scenarios. + """ + + def __enter__(self): + """Enter the managed environment.""" + self.stack = ExitStack() + self.environ = self.stack.enter_context( + os_helper.EnvironmentVarGuard(), + ) + self.tmpdir = self.stack.enter_context(os_helper.temp_dir()) + return self + + def __exit__(self, *ignore_exc): + """Exit the managed environment and performs cleanup. + + This method closes the `ExitStack`, which automatically cleans up the + temporary directory and environment. + """ + self.stack.close() + + def generate_netrc( + self, content, filename=".netrc", mode=0o600, encoding=None, + ): + """Create and return the path to a temporary `.netrc` file.""" + netrc_file = os.path.join(self.tmpdir, filename) + write_mode = "w" if sys.platform != "cygwin" else "wt" + with open(netrc_file, mode=write_mode, encoding=encoding) as fp: + fp.write(textwrap.dedent(content)) + if os_helper.can_chmod(): + os.chmod(netrc_file, mode=mode) + return netrc_file + + +def use_default_netrc_in_home(*args, **kwargs): + """Load an instance of netrc using the default `.netrc` file from the + user's home directory. + """ + with NetrcEnvironment() as helper: + helper.generate_netrc(*args, **kwargs) + helper.environ.unset("NETRC") + helper.environ.set("HOME", helper.tmpdir) + with mock.patch("os.path.expanduser") as mock_expanduser: + def fake_expanduser(path): + return helper.tmpdir if path == "~" else os.path.expanduser(path) + mock_expanduser.side_effect = fake_expanduser + return netrc.netrc() + + +def use_netrc_envvar(*args, **kwargs): + """Load an instance of the netrc using the `.netrc` file specified by + the `NETRC` environment variable. + """ + with NetrcEnvironment() as helper: + netrc_file = helper.generate_netrc(*args, **kwargs) + helper.environ.set("NETRC", netrc_file) + return netrc.netrc() + + +def use_file_argument(*args, **kwargs): + """Load an instance of `.netrc` file using the file as argument. + """ + with NetrcEnvironment() as helper: + # Just to stress a bit more the test scenario, the NETRC envvar + # will contain rubish information which shouldn't be used + helper.environ.set("NETRC", "not-a-file.netrc") + netrc_file = helper.generate_netrc(*args, **kwargs) + return netrc.netrc(netrc_file) + + +def get_all_scenarios(): + """Return all `.netrc` loading scenarios as callables. + + This method is useful for iterating through all ways the + `.netrc` file can be located. + """ + return (use_default_netrc_in_home, + use_netrc_envvar, + use_file_argument) -temp_filename = os_helper.TESTFN class NetrcTestCase(unittest.TestCase): + ALL_NETRC_FILE_SCENARIOS = get_all_scenarios() - def make_nrc(self, test_data): - test_data = textwrap.dedent(test_data) - mode = 'w' - if sys.platform != 'cygwin': - mode += 't' - with open(temp_filename, mode, encoding="utf-8") as fp: - fp.write(test_data) - try: - nrc = netrc.netrc(temp_filename) - finally: - os.unlink(temp_filename) - return nrc - - def test_toplevel_non_ordered_tokens(self): - nrc = self.make_nrc("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_toplevel_non_ordered_tokens(self, make_nrc): + nrc = make_nrc("""\ machine host.domain.com password pass1 login log1 account acct1 default login log2 password pass2 account acct2 """) self.assertEqual(nrc.hosts['host.domain.com'], ('log1', 'acct1', 'pass1')) self.assertEqual(nrc.hosts['default'], ('log2', 'acct2', 'pass2')) - def test_toplevel_tokens(self): - nrc = self.make_nrc("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_toplevel_tokens(self, make_nrc): + nrc = make_nrc("""\ machine host.domain.com login log1 password pass1 account acct1 default login log2 password pass2 account acct2 """) self.assertEqual(nrc.hosts['host.domain.com'], ('log1', 'acct1', 'pass1')) self.assertEqual(nrc.hosts['default'], ('log2', 'acct2', 'pass2')) - def test_macros(self): + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_macros(self, make_nrc): data = """\ macdef macro1 line1 @@ -46,14 +124,15 @@ def test_macros(self): line4 """ - nrc = self.make_nrc(data) + nrc = make_nrc(data) self.assertEqual(nrc.macros, {'macro1': ['line1\n', 'line2\n'], 'macro2': ['line3\n', 'line4\n']}) # strip the last \n - self.assertRaises(netrc.NetrcParseError, self.make_nrc, + self.assertRaises(netrc.NetrcParseError, make_nrc, data.rstrip(' ')[:-1]) - def test_optional_tokens(self): + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_optional_tokens(self, make_nrc): data = ( "machine host.domain.com", "machine host.domain.com login", @@ -64,7 +143,7 @@ def test_optional_tokens(self): "machine host.domain.com account \"\" password" ) for item in data: - nrc = self.make_nrc(item) + nrc = make_nrc(item) self.assertEqual(nrc.hosts['host.domain.com'], ('', '', '')) data = ( "default", @@ -76,10 +155,11 @@ def test_optional_tokens(self): "default account \"\" password" ) for item in data: - nrc = self.make_nrc(item) + nrc = make_nrc(item) self.assertEqual(nrc.hosts['default'], ('', '', '')) - def test_invalid_tokens(self): + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_invalid_tokens(self, make_nrc): data = ( "invalid host.domain.com", "machine host.domain.com invalid", @@ -88,10 +168,10 @@ def test_invalid_tokens(self): "default host.domain.com login log password pass account acct invalid" ) for item in data: - self.assertRaises(netrc.NetrcParseError, self.make_nrc, item) + self.assertRaises(netrc.NetrcParseError, make_nrc, item) - def _test_token_x(self, nrc, token, value): - nrc = self.make_nrc(nrc) + def _test_token_x(self, make_nrc, content, token, value): + nrc = make_nrc(content) if token == 'login': self.assertEqual(nrc.hosts['host.domain.com'], (value, 'acct', 'pass')) elif token == 'account': @@ -99,168 +179,184 @@ def _test_token_x(self, nrc, token, value): elif token == 'password': self.assertEqual(nrc.hosts['host.domain.com'], ('log', 'acct', value)) - def test_token_value_quotes(self): - self._test_token_x("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_token_value_quotes(self, make_nrc): + self._test_token_x(make_nrc, """\ machine host.domain.com login "log" password pass account acct """, 'login', 'log') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account "acct" """, 'account', 'acct') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password "pass" account acct """, 'password', 'pass') - def test_token_value_escape(self): - self._test_token_x("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_token_value_escape(self, make_nrc): + self._test_token_x(make_nrc, """\ machine host.domain.com login \\"log password pass account acct """, 'login', '"log') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login "\\"log" password pass account acct """, 'login', '"log') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account \\"acct """, 'account', '"acct') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account "\\"acct" """, 'account', '"acct') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password \\"pass account acct """, 'password', '"pass') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password "\\"pass" account acct """, 'password', '"pass') - def test_token_value_whitespace(self): - self._test_token_x("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_token_value_whitespace(self, make_nrc): + self._test_token_x(make_nrc, """\ machine host.domain.com login "lo g" password pass account acct """, 'login', 'lo g') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password "pas s" account acct """, 'password', 'pas s') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account "acc t" """, 'account', 'acc t') - def test_token_value_non_ascii(self): - self._test_token_x("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_token_value_non_ascii(self, make_nrc): + self._test_token_x(make_nrc, """\ machine host.domain.com login \xa1\xa2 password pass account acct """, 'login', '\xa1\xa2') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account \xa1\xa2 """, 'account', '\xa1\xa2') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password \xa1\xa2 account acct """, 'password', '\xa1\xa2') - def test_token_value_leading_hash(self): - self._test_token_x("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_token_value_leading_hash(self, make_nrc): + self._test_token_x(make_nrc, """\ machine host.domain.com login #log password pass account acct """, 'login', '#log') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account #acct """, 'account', '#acct') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password #pass account acct """, 'password', '#pass') - def test_token_value_trailing_hash(self): - self._test_token_x("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_token_value_trailing_hash(self, make_nrc): + self._test_token_x(make_nrc, """\ machine host.domain.com login log# password pass account acct """, 'login', 'log#') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account acct# """, 'account', 'acct#') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass# account acct """, 'password', 'pass#') - def test_token_value_internal_hash(self): - self._test_token_x("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_token_value_internal_hash(self, make_nrc): + self._test_token_x(make_nrc, """\ machine host.domain.com login lo#g password pass account acct """, 'login', 'lo#g') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pass account ac#ct """, 'account', 'ac#ct') - self._test_token_x("""\ + self._test_token_x(make_nrc, """\ machine host.domain.com login log password pa#ss account acct """, 'password', 'pa#ss') - def _test_comment(self, nrc, passwd='pass'): - nrc = self.make_nrc(nrc) + def _test_comment(self, make_nrc, content, passwd='pass'): + nrc = make_nrc(content) self.assertEqual(nrc.hosts['foo.domain.com'], ('bar', '', passwd)) self.assertEqual(nrc.hosts['bar.domain.com'], ('foo', '', 'pass')) - def test_comment_before_machine_line(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_before_machine_line(self, make_nrc): + self._test_comment(make_nrc, """\ # comment machine foo.domain.com login bar password pass machine bar.domain.com login foo password pass """) - def test_comment_before_machine_line_no_space(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_before_machine_line_no_space(self, make_nrc): + self._test_comment(make_nrc, """\ #comment machine foo.domain.com login bar password pass machine bar.domain.com login foo password pass """) - def test_comment_before_machine_line_hash_only(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_before_machine_line_hash_only(self, make_nrc): + self._test_comment(make_nrc, """\ # machine foo.domain.com login bar password pass machine bar.domain.com login foo password pass """) - def test_comment_after_machine_line(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_after_machine_line(self, make_nrc): + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass # comment machine bar.domain.com login foo password pass """) - self._test_comment("""\ + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass machine bar.domain.com login foo password pass # comment """) - def test_comment_after_machine_line_no_space(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_after_machine_line_no_space(self, make_nrc): + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass #comment machine bar.domain.com login foo password pass """) - self._test_comment("""\ + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass machine bar.domain.com login foo password pass #comment """) - def test_comment_after_machine_line_hash_only(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_after_machine_line_hash_only(self, make_nrc): + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass # machine bar.domain.com login foo password pass """) - self._test_comment("""\ + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass machine bar.domain.com login foo password pass # """) - def test_comment_at_end_of_machine_line(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_at_end_of_machine_line(self, make_nrc): + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass # comment machine bar.domain.com login foo password pass """) - def test_comment_at_end_of_machine_line_no_space(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_at_end_of_machine_line_no_space(self, make_nrc): + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password pass #comment machine bar.domain.com login foo password pass """) - def test_comment_at_end_of_machine_line_pass_has_hash(self): - self._test_comment("""\ + @subTests('make_nrc', ALL_NETRC_FILE_SCENARIOS) + def test_comment_at_end_of_machine_line_pass_has_hash(self, make_nrc): + self._test_comment(make_nrc, """\ machine foo.domain.com login bar password #pass #comment machine bar.domain.com login foo password pass """, '#pass') diff --git a/Misc/NEWS.d/next/Library/2025-06-22-09-04-24.gh-issue-135788.XLT8TW.rst b/Misc/NEWS.d/next/Library/2025-06-22-09-04-24.gh-issue-135788.XLT8TW.rst new file mode 100644 index 00000000000000..5655cd204158b4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-06-22-09-04-24.gh-issue-135788.XLT8TW.rst @@ -0,0 +1,4 @@ +The :mod:`netrc` module now checks the :envvar:`!NETRC` environment +variable when no file path is explicitly passed to :func:`netrc.netrc`. +If :envvar:`!NETRC` is not set, it falls back to the :file:`.netrc` file in the +user's home directory.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: