From 622d569244d07784d505bad3c8f2f1047d41cca5 Mon Sep 17 00:00:00 2001 From: Manu Garg Date: Sat, 22 Aug 2026 14:19:02 -0700 Subject: [PATCH] Add pactester console entrypoint to the python package pipx and uv tool install fail with 'No executables are provided by package pacparser' because the python package did not declare any console entrypoints. Add a python implementation of the pactester CLI (same interface as the C tool) and register it as the 'pactester' console script, so the package installs an executable via pip, pipx and uv tool. --- src/pymod/MANIFEST.in | 1 + src/pymod/pacparser/pactester.py | 203 +++++++++++++++++++++++++++++++ src/pymod/setup.py | 7 +- tests/runtests.py | 35 ++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/pymod/pacparser/pactester.py diff --git a/src/pymod/MANIFEST.in b/src/pymod/MANIFEST.in index ce413744..db964feb 100644 --- a/src/pymod/MANIFEST.in +++ b/src/pymod/MANIFEST.in @@ -1,5 +1,6 @@ include pacparser_py.c include pacparser/__init__.py +include pacparser/pactester.py include pacparser.h include pacparser.c include pac_utils.h diff --git a/src/pymod/pacparser/pactester.py b/src/pymod/pacparser/pactester.py new file mode 100644 index 00000000..ef8535fe --- /dev/null +++ b/src/pymod/pacparser/pactester.py @@ -0,0 +1,203 @@ +# Copyright (C) 2007-2026 Manu Garg. +# Author: Manu Garg +# +# pacparser is a library that provides methods to parse proxy auto-config +# (PAC) files. Please read README file included with this package for more +# information about this library. +# +# pacparser is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +# pacparser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +""" +Command line tool to test PAC files. It provides the same interface as the +C pactester tool: + + pactester <-p pacfile> <-u url> [-h host] [-c client_ip] [-e] + pactester <-p pacfile> <-f urlslist> [-c client_ip] [-e] +""" + +import getopt +import sys + +import pacparser + +PACMAX = 1024 * 1024 # Max size of the PAC script (1 MiB) + +PROGNAME = "pactester" + +def usage(): + sys.stderr.write("\nUsage: %s <-p pacfile> <-u url> [-h host] " + "[-c client_ip] [-e]\n" % PROGNAME) + sys.stderr.write(" %s <-p pacfile> <-f urlslist> " + "[-c client_ip] [-e]\n\n" % PROGNAME) + sys.stderr.write("Options:\n") + sys.stderr.write(" -p pacfile : PAC file to test (specify '-' to read " + "from standard input)\n") + sys.stderr.write(" -u url : URL to test for\n") + sys.stderr.write(" -h host : Host part of the URL\n") + sys.stderr.write(" -c client_ip : client IP address (as returned by " + "myIpAddres() function\n") + sys.stderr.write(" in PAC files), defaults to IP address " + "on which it is running.\n") + sys.stderr.write(" -e : Deprecated: IPv6 extensions are enabled" + "by default now.\n") + sys.stderr.write(" -f urlslist : a file containing list of URLs to be " + "tested.\n") + sys.stderr.write(" -v : print version and exit\n") + sys.exit(1) + +def get_host_from_url(url): + # Extract the host part of the URL, like the C pactester does. Prints an + # error and returns None if the URL is not in a recognized form. + idx = url.find(':') + if idx < 0 or url[idx + 1:idx + 3] != '//': + sys.stderr.write("pactester: Not a proper URL\n") + return None + start = idx + 3 + if start >= len(url) or url[start] in ('/', ':'): + sys.stderr.write("pactester: Not a proper URL\n") + return None + for end in range(start, len(url)): + if url[end] in ('/', ':'): + break + else: + end = len(url) + host = url[start:end] + if not host: + sys.stderr.write("pactester: Not a proper URL\n") + return None + return host + +def main(): + argv = sys.argv[1:] + if argv and argv[0] in ("--help", "--helpshort"): + usage() + + pacfile = url = host = urlslist = client_ip = None + + try: + opts, _ = getopt.getopt(argv, "evp:u:h:f:c:") + except getopt.GetoptError: + usage() + + for opt, arg in opts: + if opt == "-v": + print(pacparser.version()) + return 0 + elif opt == "-p": + pacfile = arg + elif opt == "-u": + url = arg + elif opt == "-h": + host = arg + elif opt == "-f": + urlslist = arg + elif opt == "-c": + client_ip = arg + elif opt == "-e": + pass + + if not pacfile: + sys.stderr.write("pactester: You didn't specify the PAC file\n") + usage() + if not url and not urlslist: + sys.stderr.write("pactester: You didn't specify the URL\n") + usage() + + try: + pacparser.init() + except Exception: + sys.stderr.write("pactester: Could not initialize pacparser\n") + return 1 + + if pacfile == "-": + script = sys.stdin.read() + if len(script) > PACMAX: + sys.stderr.write("Input file is too big. Maximum allowed size is: %d" + % PACMAX) + pacparser.cleanup() + return 1 + try: + pacparser.parse_pac_string(script) + except Exception: + sys.stderr.write("pactester: Could not parse the pac script: %s\n" + % script) + pacparser.cleanup() + return 1 + else: + try: + pacparser.parse_pac_file(pacfile) + except Exception: + sys.stderr.write("pactester: Could not parse the pac file: %s\n" + % pacfile) + pacparser.cleanup() + return 1 + + if client_ip: + pacparser.setmyip(client_ip) + + if url: + if not host: + host = get_host_from_url(url) + if not host: + pacparser.cleanup() + return 1 + try: + proxy = pacparser.find_proxy(url, host) + except Exception: + sys.stderr.write("pactester: Problem in finding proxy for %s.\n" % url) + pacparser.cleanup() + return 1 + print(proxy) + pacparser.cleanup() + return 0 + + if urlslist: + try: + fp = open(urlslist, "r") + except IOError: + sys.stderr.write("pactester: Could not open urlslist: %s" % urlslist) + pacparser.cleanup() + return 1 + for line in fp: + u = line.lstrip(" \t") + # Skip comment lines, echoing them as the C pactester does. + if u.startswith("#"): + sys.stdout.write(u) + continue + fields = u.split(None, 1) + if not fields: + continue + u = fields[0] + host = get_host_from_url(u) + if not host: + continue + try: + proxy = pacparser.find_proxy(u, host) + except Exception: + sys.stderr.write("pactester: Problem in finding proxy for %s.\n" % u) + pacparser.cleanup() + return 1 + if proxy: + print("%s : %s" % (u, proxy)) + fp.close() + pacparser.cleanup() + return 0 + + pacparser.cleanup() + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/pymod/setup.py b/src/pymod/setup.py index b26094c9..5fb448bf 100644 --- a/src/pymod/setup.py +++ b/src/pymod/setup.py @@ -274,7 +274,12 @@ def main(patched_func): license="LGPL", ext_package="pacparser", ext_modules=[pacparser_module], - py_modules=["pacparser.__init__"], + py_modules=["pacparser.__init__", "pacparser.pactester"], + entry_points={ + "console_scripts": [ + "pactester = pacparser.pactester:main", + ], + }, ) diff --git a/tests/runtests.py b/tests/runtests.py index 4c551912..d9596048 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -23,6 +23,7 @@ import getopt import glob import os +import subprocess import sys import tempfile @@ -35,6 +36,38 @@ def module_path(tests_dir): return glob.glob(os.path.join(builddir, 'lib*%s' % py_ver))[0] +def run_cli_tests(pacfile, testdata, pacparser_module_path): + # Run the same testdata lines through the python command line tool + # (python -m pacparser.pactester), the way runtests.sh does for the C + # pactester. + env = dict(os.environ) + env['PYTHONPATH'] = (pacparser_module_path + os.pathsep + + env.get('PYTHONPATH', '')) + f = open(testdata) + for line in f: + comment = '' + if '#' in line: + comment = line.split('#', 1)[1] + line = line.split('#', 1)[0].strip() + if not line: + continue + if ('NO_INTERNET' in os.environ and os.environ['NO_INTERNET'] and + 'INTERNET_REQUIRED' in comment): + continue + (params, expected_result) = line.strip().split('|') + cmd = [sys.executable, '-m', 'pacparser.pactester', '-p', pacfile] + cmd += params.split() + p = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if p.returncode != 0: + raise Exception('CLI test failed: %s returned %d\nstdout: %s\n' + 'stderr: %s' + % (' '.join(cmd), p.returncode, p.stdout, p.stderr)) + if p.stdout.strip() != expected_result: + raise Exception('CLI test failed. Got "%s", expected "%s"' + % (p.stdout.strip(), expected_result)) + f.close() + def runtests(pacfile, testdata, tests_dir): try: pacparser_module_path = module_path(tests_dir) @@ -104,6 +137,8 @@ def runtests(pacfile, testdata, tests_dir): raise Exception('Logging test failed: stderr mismatch\nExpected:\n%s\nGot:\n%s' % (expected_stderr, actual_stderr)) + run_cli_tests(pacfile, testdata, pacparser_module_path) + print('All tests were successful.')