diff --git a/.github/workflows/test-brew.yml b/.github/workflows/test-brew.yml index c2cff08779..7c31ce1f50 100644 --- a/.github/workflows/test-brew.yml +++ b/.github/workflows/test-brew.yml @@ -1,12 +1,12 @@ --- -name: Proxy.py Brew +name: brew on: [push, pull_request] # yamllint disable-line rule:truthy jobs: build: runs-on: ${{ matrix.os }}-latest - name: Brew - Python ${{ matrix.python }} on ${{ matrix.os }} + name: 🐍${{ matrix.python }} @ ${{ matrix.os }} strategy: matrix: os: [macOS] diff --git a/.github/workflows/test-dashboard.yml b/.github/workflows/test-dashboard.yml index 513256f9dc..7965993ce9 100644 --- a/.github/workflows/test-dashboard.yml +++ b/.github/workflows/test-dashboard.yml @@ -1,12 +1,12 @@ --- -name: Proxy.py Dashboard +name: dashboard on: [push, pull_request] # yamllint disable-line rule:truthy jobs: build: runs-on: ${{ matrix.os }}-latest - name: Dashboard - Node ${{ matrix.node }} on ${{ matrix.os }} + name: Node ${{ matrix.node }} @ ${{ matrix.os }} strategy: matrix: os: [macOS, ubuntu, windows] diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index d6dfef6ee2..2d81652db7 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -1,12 +1,12 @@ --- -name: Proxy.py Docker +name: docker on: [push, pull_request] # yamllint disable-line rule:truthy jobs: build: runs-on: ${{ matrix.os }}-latest - name: Docker - Python ${{ matrix.python }} on ${{ matrix.os }} + name: 🐍${{ matrix.python }} @ ${{ matrix.os }} strategy: matrix: os: [ubuntu] diff --git a/helper/homebrew/develop/proxy.rb b/helper/homebrew/develop/proxy.rb index d695e53ed8..37cff9a783 100644 --- a/helper/homebrew/develop/proxy.rb +++ b/helper/homebrew/develop/proxy.rb @@ -9,11 +9,6 @@ class Proxy < Formula depends_on "python" - resource "typing-extensions" do - url "https://files.pythonhosted.org/packages/6a/28/d32852f2af6b5ead85d396249d5bdf450833f3a69896d76eb480d9c5e406/typing_extensions-3.7.4.2.tar.gz" - sha256 "79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae" - end - def install virtualenv_install_with_resources end diff --git a/helper/homebrew/stable/proxy.rb b/helper/homebrew/stable/proxy.rb index 83b38cbb1a..7d0e3a9fc5 100644 --- a/helper/homebrew/stable/proxy.rb +++ b/helper/homebrew/stable/proxy.rb @@ -5,15 +5,11 @@ class Proxy < Formula Network monitoring, controls & Application development, testing, debugging." homepage "https://github.com/abhinavsingh/proxy.py" url "https://github.com/abhinavsingh/proxy.py/archive/master.zip" - version "2.2.0" + sha256 "715687cebd451285d266f29d6509a64becc93da21f61ba9b4414e7dc4ecaaeed" + version "2.3.1" depends_on "python" - resource "typing-extensions" do - url "https://files.pythonhosted.org/packages/6a/28/d32852f2af6b5ead85d396249d5bdf450833f3a69896d76eb480d9c5e406/typing_extensions-3.7.4.2.tar.gz" - sha256 "79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae" - end - def install virtualenv_install_with_resources end diff --git a/proxy/core/acceptor/threadless.py b/proxy/core/acceptor/threadless.py index 8caf21739b..5ed8ac36e2 100644 --- a/proxy/core/acceptor/threadless.py +++ b/proxy/core/acceptor/threadless.py @@ -198,8 +198,6 @@ def run(self) -> None: self.loop = asyncio.get_event_loop() while not self.running.is_set(): self.run_once() - except KeyboardInterrupt: - pass finally: assert self.selector is not None self.selector.unregister(self.client_queue) diff --git a/proxy/core/acceptor/work.py b/proxy/core/acceptor/work.py index dcfabc2831..5556a31941 100644 --- a/proxy/core/acceptor/work.py +++ b/proxy/core/acceptor/work.py @@ -73,7 +73,7 @@ def run(self) -> None: compatibility with threaded mode where work class is started as a separate thread. """ - pass + pass # pragma: no cover def publish_event( self, diff --git a/proxy/http/parser.py b/proxy/http/parser.py index 597e641de1..927ca44270 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -180,7 +180,8 @@ def parse(self, raw: bytes) -> None: more = False else: raise NotImplementedError( - 'Parser shouldn\'t have reached here', + 'Parser shouldn\'t have reached here. ' + + 'This can happen when content length header is missing but their is a body in the payload', ) else: more, raw = self.process(raw) @@ -285,7 +286,8 @@ def build_response(self) -> bytes: headers={} if not self.headers else { self.headers[k][0]: self.headers[k][1] for k in self.headers }, - body=self.body if not self.is_chunked_encoded() else ChunkParser.to_chunks(self.body), + body=self.body if not self.is_chunked_encoded( + ) else ChunkParser.to_chunks(self.body), ) def has_host(self) -> bool: diff --git a/proxy/proxy.py b/proxy/proxy.py index 330c7a9cdb..988dec4535 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -23,7 +23,7 @@ import inspect from types import TracebackType -from typing import Dict, List, Optional, Any, Tuple, Type, Union, cast +from typing import Dict, List, Optional, Any, Type, Union, cast from proxy.core.acceptor.work import Work @@ -205,7 +205,7 @@ def initialize( if input_args is None: input_args = [] - if not Proxy.is_py3(): + if Proxy.is_py2(): print(PY2_DEPRECATION_MESSAGE) sys.exit(1) @@ -230,16 +230,15 @@ def initialize( Proxy.set_open_file_limit(args.open_file_limit) # Load plugins - default_plugins = Proxy.get_default_plugins(args) + default_plugins = [bytes_(p) for p in Proxy.get_default_plugins(args)] + extra_plugins = [ + p if isinstance(p, type) else bytes_(p) + for p in opts.get('plugins', args.plugins.split(text_(COMMA))) + if not (isinstance(p, str) and len(p) == 0) + ] # Load default plugins along with user provided --plugins - plugins = Proxy.load_plugins( - [bytes_(p) for p in collections.OrderedDict(default_plugins).keys()] + - [ - p if isinstance(p, type) else bytes_(p) - for p in opts.get('plugins', args.plugins.split(text_(COMMA))) - ], - ) + plugins = Proxy.load_plugins(default_plugins + extra_plugins) # proxy.py currently cannot serve over HTTPS and also perform TLS interception # at the same time. Check if user is trying to enable both feature @@ -413,8 +412,7 @@ def load_plugins( } for plugin_ in plugins: klass, module_name = Proxy.import_plugin(plugin_) - if klass is None and module_name is None: - continue + assert klass and module_name mro = list(inspect.getmro(klass)) mro.reverse() iterator = iter(mro) @@ -433,8 +431,7 @@ def import_plugin(plugin: Union[bytes, type]) -> Any: klass = plugin else: plugin_ = text_(plugin.strip()) - if plugin_ == '': - return (None, None) + assert plugin_ != '' module_name, klass_name = plugin_.rsplit(text_(DOT), 1) klass = getattr( importlib.import_module( @@ -449,36 +446,37 @@ def import_plugin(plugin: Union[bytes, type]) -> Any: @staticmethod def get_default_plugins( args: argparse.Namespace, - ) -> List[Tuple[str, bool]]: - # Prepare list of plugins to load based upon - # --enable-*, --disable-* and --basic-auth flags. - default_plugins: List[Tuple[str, bool]] = [] + ) -> List[str]: + """Prepare list of plugins to load based upon + --enable-*, --disable-* and --basic-auth flags. + """ + default_plugins: List[str] = [] if args.basic_auth is not None: - default_plugins.append((PLUGIN_PROXY_AUTH, True)) + default_plugins.append(PLUGIN_PROXY_AUTH) if args.enable_dashboard: - default_plugins.append((PLUGIN_WEB_SERVER, True)) + default_plugins.append(PLUGIN_WEB_SERVER) args.enable_static_server = True - default_plugins.append((PLUGIN_DASHBOARD, True)) - default_plugins.append((PLUGIN_INSPECT_TRAFFIC, True)) + default_plugins.append(PLUGIN_DASHBOARD) + default_plugins.append(PLUGIN_INSPECT_TRAFFIC) args.enable_events = True args.enable_devtools = True if args.enable_devtools: - default_plugins.append((PLUGIN_DEVTOOLS_PROTOCOL, True)) - default_plugins.append((PLUGIN_WEB_SERVER, True)) + default_plugins.append(PLUGIN_DEVTOOLS_PROTOCOL) + default_plugins.append(PLUGIN_WEB_SERVER) if not args.disable_http_proxy: - default_plugins.append((PLUGIN_HTTP_PROXY, True)) + default_plugins.append(PLUGIN_HTTP_PROXY) if args.enable_web_server or \ args.pac_file is not None or \ args.enable_static_server: - default_plugins.append((PLUGIN_WEB_SERVER, True)) + default_plugins.append(PLUGIN_WEB_SERVER) if args.pac_file is not None: - default_plugins.append((PLUGIN_PAC_FILE, True)) - return default_plugins + default_plugins.append(PLUGIN_PAC_FILE) + return list(collections.OrderedDict.fromkeys(default_plugins).keys()) @staticmethod - def is_py3() -> bool: + def is_py2() -> bool: """Exists only to avoid mocking sys.version_info in tests.""" - return sys.version_info[0] != 2 + return sys.version_info[0] == 2 @staticmethod def set_open_file_limit(soft_limit: int) -> None: @@ -515,7 +513,7 @@ def main( # at runtime. Example, updating flags, plugin # configuration etc. # - # TODO: Python shell within running proxy.py environment + # TODO: Python shell within running proxy.py environment? while True: time.sleep(1) except KeyboardInterrupt: diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py index ded49c8283..29e0cebdf2 100644 --- a/tests/http/test_http_parser.py +++ b/tests/http/test_http_parser.py @@ -272,7 +272,10 @@ def test_get_partial_parse2(self) -> None: self.parser.parse(b'Content-Type: text/plain' + CRLF) self.assertEqual(self.parser.buffer, b'') self.assertEqual( - self.parser.headers[b'content-type'], (b'Content-Type', b'text/plain'), + self.parser.headers[b'content-type'], ( + b'Content-Type', + b'text/plain', + ), ) self.assertEqual( self.parser.state, @@ -632,3 +635,33 @@ def test_paramiko_doc(self) -> None: self.parser = HttpParser(httpParserTypes.RESPONSE_PARSER) self.parser.parse(response) self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_request_factory(self) -> None: + r = HttpParser.request( + b'POST http://localhost:12345 HTTP/1.1' + CRLF + + b'key: value' + CRLF + + b'Content-Length: 13' + CRLF + CRLF + + b'Hello from py', + ) + self.assertEqual(r.host, b'localhost') + self.assertEqual(r.port, 12345) + self.assertEqual(r.path, b'/') + self.assertEqual(r.header(b'key'), b'value') + self.assertEqual(r.header(b'KEY'), b'value') + self.assertEqual(r.header(b'content-length'), b'13') + self.assertEqual(r.body, b'Hello from py') + + def test_response_factory(self) -> None: + r = HttpParser.response( + b'HTTP/1.1 200 OK\r\nkey: value\r\n\r\n', + ) + self.assertEqual(r.code, b'200') + self.assertEqual(r.reason, b'OK') + self.assertEqual(r.header(b'key'), b'value') + + def test_parser_shouldnt_have_reached_here(self) -> None: + with self.assertRaises(NotImplementedError): + HttpParser.request( + b'POST http://localhost:12345 HTTP/1.1' + CRLF + + b'key: value' + CRLF + CRLF + b'Hello from py', + ) diff --git a/tests/http/test_http_proxy.py b/tests/http/test_http_proxy.py index 13a55a5b3c..5616b9e75f 100644 --- a/tests/http/test_http_proxy.py +++ b/tests/http/test_http_proxy.py @@ -115,3 +115,42 @@ def test_proxy_plugin_before_upstream_connection_can_teardown( self.protocol_handler.run_once() mock_server_conn.assert_not_called() self.plugin.return_value.before_upstream_connection.assert_called() + + def test_proxy_plugin_plugins_can_teardown_from_write_to_descriptors(self) -> None: + pass + + def test_proxy_plugin_retries_on_ssl_want_write_error(self) -> None: + pass + + def test_proxy_plugin_broken_pipe_error_on_write_will_teardown(self) -> None: + pass + + def test_proxy_plugin_plugins_can_teardown_from_read_from_descriptors(self) -> None: + pass + + def test_proxy_plugin_retries_on_ssl_want_read_error(self) -> None: + pass + + def test_proxy_plugin_timeout_error_on_read_will_teardown(self) -> None: + pass + + def test_proxy_plugin_invokes_handle_pipeline_response(self) -> None: + pass + + def test_proxy_plugin_invokes_on_access_log(self) -> None: + pass + + def test_proxy_plugin_skips_server_teardown_when_client_closes_and_server_never_initialized(self) -> None: + pass + + def test_proxy_plugin_invokes_handle_client_data(self) -> None: + pass + + def test_proxy_plugin_handles_pipeline_response(self) -> None: + pass + + def test_proxy_plugin_invokes_resolve_dns(self) -> None: + pass + + def test_proxy_plugin_require_both_host_port_to_connect(self) -> None: + pass diff --git a/tests/test_main.py b/tests/test_main.py index ec99edbc58..8f4ffba2d9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -13,18 +13,17 @@ import os from unittest import mock -from typing import List -from proxy.proxy import main, Proxy +from proxy.proxy import main, Proxy, entry_point from proxy.common.utils import bytes_ from proxy.http.handler import HttpProtocolHandler -from proxy.common.constants import DEFAULT_LOG_LEVEL, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT, DEFAULT_BASIC_AUTH +from proxy.common.constants import DEFAULT_ENABLE_DASHBOARD, DEFAULT_LOG_LEVEL, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT from proxy.common.constants import DEFAULT_TIMEOUT, DEFAULT_DEVTOOLS_WS_PATH, DEFAULT_DISABLE_HTTP_PROXY from proxy.common.constants import DEFAULT_ENABLE_STATIC_SERVER, DEFAULT_ENABLE_EVENTS, DEFAULT_ENABLE_DEVTOOLS from proxy.common.constants import DEFAULT_ENABLE_WEB_SERVER, DEFAULT_THREADLESS, DEFAULT_CERT_FILE, DEFAULT_KEY_FILE from proxy.common.constants import DEFAULT_CA_CERT_FILE, DEFAULT_CA_KEY_FILE, DEFAULT_CA_SIGNING_KEY_FILE -from proxy.common.constants import DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT +from proxy.common.constants import DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT, DEFAULT_BASIC_AUTH from proxy.common.constants import DEFAULT_NUM_WORKERS, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_IPV6_HOSTNAME from proxy.common.constants import DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CLIENT_RECVBUF_SIZE, PY2_DEPRECATION_MESSAGE from proxy.common.version import __version__ @@ -66,25 +65,46 @@ def mock_default_args(mock_args: mock.Mock) -> None: mock_args.enable_static_server = DEFAULT_ENABLE_STATIC_SERVER mock_args.enable_devtools = DEFAULT_ENABLE_DEVTOOLS mock_args.enable_events = DEFAULT_ENABLE_EVENTS + mock_args.enable_dashboard = DEFAULT_ENABLE_DASHBOARD @mock.patch('time.sleep') @mock.patch('proxy.proxy.Proxy.initialize') @mock.patch('proxy.proxy.EventManager') @mock.patch('proxy.proxy.AcceptorPool') - @mock.patch('logging.basicConfig') - def test_init_with_no_arguments( + def test_entry_point( self, - mock_logging_config: mock.Mock, mock_acceptor_pool: mock.Mock, mock_event_manager: mock.Mock, mock_initialize: mock.Mock, mock_sleep: mock.Mock, ) -> None: mock_sleep.side_effect = KeyboardInterrupt() + mock_initialize.return_value.enable_events = False + entry_point() + mock_event_manager.assert_not_called() + mock_acceptor_pool.assert_called_with( + flags=mock_initialize.return_value, + work_klass=HttpProtocolHandler, + event_queue=None, + ) + mock_acceptor_pool.return_value.setup.assert_called() + mock_acceptor_pool.return_value.shutdown.assert_called() + mock_sleep.assert_called() - input_args: List[str] = [] + @mock.patch('time.sleep') + @mock.patch('proxy.proxy.Proxy.initialize') + @mock.patch('proxy.proxy.EventManager') + @mock.patch('proxy.proxy.AcceptorPool') + def test_main_with_no_arguments( + self, + mock_acceptor_pool: mock.Mock, + mock_event_manager: mock.Mock, + mock_initialize: mock.Mock, + mock_sleep: mock.Mock, + ) -> None: + mock_sleep.side_effect = KeyboardInterrupt() mock_initialize.return_value.enable_events = False - main(input_args) + main([]) mock_event_manager.assert_not_called() mock_acceptor_pool.assert_called_with( flags=mock_initialize.return_value, @@ -95,6 +115,101 @@ def test_init_with_no_arguments( mock_acceptor_pool.return_value.shutdown.assert_called() mock_sleep.assert_called() + @mock.patch('time.sleep') + @mock.patch('proxy.proxy.Proxy.initialize') + @mock.patch('proxy.proxy.EventManager') + @mock.patch('proxy.proxy.AcceptorPool') + def test_enable_events( + self, + mock_acceptor_pool: mock.Mock, + mock_event_manager: mock.Mock, + mock_initialize: mock.Mock, + mock_sleep: mock.Mock, + ) -> None: + mock_sleep.side_effect = KeyboardInterrupt() + mock_initialize.return_value.enable_events = True + main([]) + mock_event_manager.assert_called_once() + mock_event_manager.return_value.start_event_dispatcher.assert_called_once() + mock_event_manager.return_value.stop_event_dispatcher.assert_called_once() + mock_acceptor_pool.assert_called_with( + flags=mock_initialize.return_value, + work_klass=HttpProtocolHandler, + event_queue=mock_event_manager.return_value.event_queue, + ) + mock_acceptor_pool.return_value.setup.assert_called() + mock_acceptor_pool.return_value.shutdown.assert_called() + mock_sleep.assert_called() + + @mock.patch('time.sleep') + @mock.patch('proxy.proxy.Proxy.load_plugins') + @mock.patch('proxy.common.flag.FlagParser.parse_args') + @mock.patch('proxy.proxy.EventManager') + @mock.patch('proxy.proxy.AcceptorPool') + def test_enable_dashboard( + self, + mock_acceptor_pool: mock.Mock, + mock_event_manager: mock.Mock, + mock_parse_args: mock.Mock, + mock_load_plugins: mock.Mock, + mock_sleep: mock.Mock, + ) -> None: + mock_sleep.side_effect = KeyboardInterrupt() + mock_args = mock_parse_args.return_value + self.mock_default_args(mock_args) + mock_args.enable_dashboard = True + main(['--enable-dashboard']) + mock_load_plugins.assert_called() + self.assertEqual( + mock_load_plugins.call_args_list[0][0][0], [ + b'proxy.http.server.HttpWebServerPlugin', + b'proxy.dashboard.dashboard.ProxyDashboard', + b'proxy.dashboard.inspect_traffic.InspectTrafficPlugin', + b'proxy.http.inspector.DevtoolsProtocolPlugin', + b'proxy.http.proxy.HttpProxyPlugin', + ], + ) + mock_parse_args.assert_called_once() + mock_acceptor_pool.assert_called() + mock_acceptor_pool.return_value.setup.assert_called() + # dashboard will also enable eventing + mock_event_manager.assert_called_once() + mock_event_manager.return_value.start_event_dispatcher.assert_called_once() + mock_event_manager.return_value.stop_event_dispatcher.assert_called_once() + + @mock.patch('time.sleep') + @mock.patch('proxy.proxy.Proxy.load_plugins') + @mock.patch('proxy.common.flag.FlagParser.parse_args') + @mock.patch('proxy.proxy.EventManager') + @mock.patch('proxy.proxy.AcceptorPool') + def test_enable_devtools( + self, + mock_acceptor_pool: mock.Mock, + mock_event_manager: mock.Mock, + mock_parse_args: mock.Mock, + mock_load_plugins: mock.Mock, + mock_sleep: mock.Mock, + ) -> None: + mock_sleep.side_effect = KeyboardInterrupt() + mock_args = mock_parse_args.return_value + self.mock_default_args(mock_args) + mock_args.enable_devtools = True + main(['--enable-devtools']) + mock_load_plugins.assert_called() + print(mock_load_plugins.call_args_list[0][0][0]) + self.assertEqual( + mock_load_plugins.call_args_list[0][0][0], [ + b'proxy.http.inspector.DevtoolsProtocolPlugin', + b'proxy.http.server.HttpWebServerPlugin', + b'proxy.http.proxy.HttpProxyPlugin', + ], + ) + mock_parse_args.assert_called_once() + mock_acceptor_pool.assert_called() + mock_acceptor_pool.return_value.setup.assert_called() + # Currently --enable-devtools alone doesn't enable eventing core + mock_event_manager.assert_not_called() + @mock.patch('time.sleep') @mock.patch('os.remove') @mock.patch('os.path.exists') @@ -117,7 +232,6 @@ def test_pid_file_is_written_and_removed( mock_args = mock_parse_args.return_value self.mock_default_args(mock_args) mock_args.pid_file = pid_file - mock_args.enable_dashboard = False main(['--pid-file', pid_file]) mock_parse_args.assert_called_once() mock_acceptor_pool.assert_called() @@ -133,7 +247,7 @@ def test_pid_file_is_written_and_removed( @mock.patch('time.sleep') @mock.patch('proxy.proxy.EventManager') @mock.patch('proxy.proxy.AcceptorPool') - def test_basic_auth( + def test_basic_auth_flag_is_base64_encoded( self, mock_acceptor_pool: mock.Mock, mock_event_manager: mock.Mock, @@ -156,10 +270,10 @@ def test_basic_auth( @mock.patch('builtins.print') @mock.patch('proxy.proxy.EventManager') @mock.patch('proxy.proxy.AcceptorPool') - @mock.patch('proxy.proxy.Proxy.is_py3') + @mock.patch('proxy.proxy.Proxy.is_py2') def test_main_py3_runs( self, - mock_is_py3: mock.Mock, + mock_is_py2: mock.Mock, mock_acceptor_pool: mock.Mock, mock_event_manager: mock.Mock, mock_print: mock.Mock, @@ -168,11 +282,11 @@ def test_main_py3_runs( mock_sleep.side_effect = KeyboardInterrupt() input_args = ['--basic-auth', 'user:pass'] - mock_is_py3.return_value = True + mock_is_py2.return_value = False main(input_args, num_workers=1) - mock_is_py3.assert_called() + mock_is_py2.assert_called() mock_print.assert_not_called() mock_event_manager.assert_not_called() @@ -180,18 +294,18 @@ def test_main_py3_runs( mock_acceptor_pool.return_value.setup.assert_called() @mock.patch('builtins.print') - @mock.patch('proxy.proxy.Proxy.is_py3') + @mock.patch('proxy.proxy.Proxy.is_py2') def test_main_py2_exit( self, - mock_is_py3: mock.Mock, + mock_is_py2: mock.Mock, mock_print: mock.Mock, ) -> None: - mock_is_py3.return_value = False + mock_is_py2.return_value = True with self.assertRaises(SystemExit) as e: main(num_workers=1) mock_print.assert_called_with(PY2_DEPRECATION_MESSAGE) self.assertEqual(e.exception.code, 1) - mock_is_py3.assert_called() + mock_is_py2.assert_called() @mock.patch('builtins.print') def test_main_version( @@ -202,3 +316,12 @@ def test_main_version( main(['--version']) mock_print.assert_called_with(__version__) self.assertEqual(e.exception.code, 0) + + # def test_pac_file(self) -> None: + # pass + + # def test_imports_plugin(self) -> None: + # pass + + # def test_cannot_enable_https_proxy_and_tls_interception_mutually(self) -> None: + # pass