diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ad68ff519..4c66f850d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1706,7 +1706,7 @@ jobs: - name: Run the other tests that read docs/ or Markdown timeout-minutes: 5 - run: python -m unittest -v tests.corekit.test_sentinel_exports_unit tests.utilities.test_config_unit + run: python -m unittest -v tests.corekit.test_sentinel_exports_unit tests.utilities.test_config_unit tests.vendor.test_vendor_variables_1726_unit # The verdict a shipping workflow asks for: the full suite, fixtures and # all, on one interpreter. It is deliberately not the matrix -- the matrix diff --git a/docs/source/pcapkit/index.rst b/docs/source/pcapkit/index.rst index 9afd67cd5..63291a133 100644 --- a/docs/source/pcapkit/index.rst +++ b/docs/source/pcapkit/index.rst @@ -148,16 +148,16 @@ Environment Variables :data:`pcapkit.utilities.logging.SPHINX_TYPE_CHECKING` -The tunable limits, and the :mod:`pcapkit.vendor` crawlers' -:envvar:`PCAPKIT_VENDOR_RETRY`, :envvar:`PCAPKIT_CI_MODE`, -:envvar:`PCAPKIT_HTTP_PROXY` and :envvar:`PCAPKIT_HTTPS_PROXY`, are read by -:mod:`pcapkit.utilities.config`, which lists each with its default, its -accepted range and its risks. +The tunable limits are read by :mod:`pcapkit.utilities.config`, which lists each +with its default, its accepted range and its risks. The :mod:`pcapkit.vendor` +crawlers' :envvar:`PCAPKIT_VENDOR_RETRY`, :envvar:`PCAPKIT_CI_MODE`, +:envvar:`PCAPKIT_HTTP_PROXY` and :envvar:`PCAPKIT_HTTPS_PROXY` are read by +:mod:`pcapkit.vendor.default`, and listed under :ref:`vendor-variables`. .. versionchanged:: 1.5.0 :envvar:`PCAPKIT_HTTP_PROXY` and :envvar:`PCAPKIT_HTTPS_PROXY` are documented - with :mod:`pcapkit.utilities.config` rather than here, and are still read on + with :mod:`pcapkit.vendor.default` rather than here, and are still read on every request. The tunable limits are new as variables (:issue:`1726`). A bad value of :envvar:`PCAPKIT_DEVMODE`, :envvar:`PCAPKIT_VERBOSE`, :envvar:`PCAPKIT_SPHINX`, :envvar:`PCAPKIT_CI_MODE` or diff --git a/docs/source/pcapkit/utilities/config.rst b/docs/source/pcapkit/utilities/config.rst index f034f59be..13d5ea9a8 100644 --- a/docs/source/pcapkit/utilities/config.rst +++ b/docs/source/pcapkit/utilities/config.rst @@ -3,10 +3,9 @@ Configuration .. module:: pcapkit.utilities.config -:mod:`pcapkit.utilities.config` holds every tunable limit of :mod:`pcapkit`, and -the settings of the :mod:`pcapkit.vendor` crawlers (:issue:`1726`). Each limit -is read from a ``PCAPKIT_*`` environment variable once, when the module is -imported: +:mod:`pcapkit.utilities.config` holds every tunable limit of :mod:`pcapkit` +(:issue:`1726`). Each limit is read from a ``PCAPKIT_*`` environment variable +once, when the module is imported: * A value that is not an integer, or is below the variable's minimum, warns with a :exc:`~pcapkit.utilities.warnings.ConfigWarning` and falls back to the @@ -22,9 +21,13 @@ limit; using the default, 10000``. It obeys the warning filters, so under ``import pcapkit`` raise :exc:`~pcapkit.utilities.warnings.ConfigWarning`. Readers look each value up as ``config.`` at call time, so patching a name -here reaches every engine and protocol that reads it. The proxy addresses are -read on every call of :func:`~pcapkit.utilities.config.http_proxy` and -:func:`~pcapkit.utilities.config.https_proxy` instead. +here reaches every engine and protocol that reads it. + +The :mod:`pcapkit.vendor` crawlers' :envvar:`PCAPKIT_VENDOR_RETRY`, +:envvar:`PCAPKIT_CI_MODE`, :envvar:`PCAPKIT_HTTP_PROXY` and +:envvar:`PCAPKIT_HTTPS_PROXY` are not held here. They are read by +:mod:`pcapkit.vendor.default`, the first two with the :ref:`readers +` below, and documented with it under :ref:`vendor-variables`. :envvar:`PCAPKIT_DEVMODE`, :envvar:`PCAPKIT_VERBOSE` and :envvar:`PCAPKIT_SPHINX` are read by :mod:`pcapkit.utilities.logging`, which needs them while it loads @@ -130,31 +133,13 @@ Limits .. autodata:: pcapkit.utilities.config.MAX_ZERO_PAD_SHORTFALL .. autodata:: pcapkit.utilities.config.ZERO_PAD_BUDGET_RATIO -Vendor Crawlers ---------------- - -.. envvar:: PCAPKIT_VENDOR_RETRY - - Sets :data:`~pcapkit.utilities.config.VENDOR_RETRY`: an integer ``>= 1``, - default ``5``. - -.. envvar:: PCAPKIT_CI_MODE - - Sets :data:`~pcapkit.utilities.config.CI_MODE`: ``1``/``yes``/``true``/``on`` - or ``0``/``no``/``false``/``off``, default off. - -.. envvar:: PCAPKIT_HTTP_PROXY - - The HTTP proxy address for the :mod:`pcapkit.vendor` crawlers, read by - :func:`~pcapkit.utilities.config.http_proxy` on every call. Unset by default. +.. _config-readers: -.. envvar:: PCAPKIT_HTTPS_PROXY +Readers +------- - The HTTPS proxy address for the :mod:`pcapkit.vendor` crawlers, read by - :func:`~pcapkit.utilities.config.https_proxy` on every call. Unset by - default. +The limits above are read with these, and a module that keeps a variable of its +own reads it with them too, so that a bad value is reported the same way. -.. autodata:: pcapkit.utilities.config.VENDOR_RETRY -.. autodata:: pcapkit.utilities.config.CI_MODE -.. autofunction:: pcapkit.utilities.config.http_proxy -.. autofunction:: pcapkit.utilities.config.https_proxy +.. autofunction:: pcapkit.utilities.config.read_limit +.. autofunction:: pcapkit.utilities.config.read_flag diff --git a/docs/source/pcapkit/vendor/default.rst b/docs/source/pcapkit/vendor/default.rst index c9f5c9bc5..c405fbe96 100644 --- a/docs/source/pcapkit/vendor/default.rst +++ b/docs/source/pcapkit/vendor/default.rst @@ -6,17 +6,6 @@ Base Crawler :mod:`pcapkit.vendor.default` contains :class:`~pcapkit.vendor.default.Vendor` only, which is the base meta class for all vendor crawlers. -.. versionremoved:: 1.5.0 - - Moved to :mod:`pcapkit.utilities.config` (:issue:`1726`): - - * ``pcapkit.vendor.default.MAX_RETRY``, now - :data:`pcapkit.utilities.config.VENDOR_RETRY`, set with - :envvar:`PCAPKIT_VENDOR_RETRY`; - * ``pcapkit.vendor.default.CI_MODE``, now - :data:`pcapkit.utilities.config.CI_MODE`, set with - :envvar:`PCAPKIT_CI_MODE`. - .. autoclass:: pcapkit.vendor.default.Vendor :no-members: :show-inheritance: @@ -40,6 +29,44 @@ only, which is the base meta class for all vendor crawlers. .. automethod:: _request .. automethod:: _dest_path +.. _vendor-variables: + +Environment Variables +--------------------- + +The crawlers' variables are read here, not by :mod:`pcapkit.utilities.config` +(:issue:`1726`). :envvar:`PCAPKIT_VENDOR_RETRY` and :envvar:`PCAPKIT_CI_MODE` +are read once, when this module is imported, with +:func:`~pcapkit.utilities.config.read_limit` and +:func:`~pcapkit.utilities.config.read_flag`: a bad value warns with a +:exc:`~pcapkit.utilities.warnings.ConfigWarning`, naming the variable and the +value, and falls back to the default. The proxy addresses are read by +:func:`~pcapkit.vendor.default.get_proxies` on every call, so a proxy set after +the import is still honoured. + +.. envvar:: PCAPKIT_VENDOR_RETRY + + Sets :data:`~pcapkit.vendor.default.MAX_RETRY`: an integer ``>= 1``, default + ``5``. + +.. envvar:: PCAPKIT_CI_MODE + + Sets :data:`~pcapkit.vendor.default.CI_MODE`: ``1``/``yes``/``true``/``on`` + or ``0``/``no``/``false``/``off``, default off. + +.. envvar:: PCAPKIT_HTTP_PROXY + + The HTTP proxy address for the crawlers, read by + :func:`~pcapkit.vendor.default.get_proxies` on every call. Unset by default. + +.. envvar:: PCAPKIT_HTTPS_PROXY + + The HTTPS proxy address for the crawlers, read by + :func:`~pcapkit.vendor.default.get_proxies` on every call. Unset by default. + +.. autodata:: pcapkit.vendor.default.MAX_RETRY +.. autodata:: pcapkit.vendor.default.CI_MODE + Internal Definitions -------------------- diff --git a/pcapkit/utilities/config.py b/pcapkit/utilities/config.py index eec6d2e63..e67357978 100644 --- a/pcapkit/utilities/config.py +++ b/pcapkit/utilities/config.py @@ -4,10 +4,9 @@ .. module:: pcapkit.utilities.config -:mod:`pcapkit.utilities.config` holds every tunable limit of :mod:`pcapkit`, -and the settings of the :mod:`pcapkit.vendor` crawlers (:issue:`1726`). Each -limit is read from a ``PCAPKIT_*`` environment variable once, when this module -is imported, and validated there: +:mod:`pcapkit.utilities.config` holds every tunable limit of :mod:`pcapkit` +(:issue:`1726`). Each limit is read from a ``PCAPKIT_*`` environment variable +once, when this module is imported, and validated there: * A value that is not an integer, or is below the variable's minimum, is reported with a :exc:`~pcapkit.utilities.warnings.ConfigWarning`, and the @@ -24,8 +23,12 @@ Every reader looks the value up here at call time, as ``config.``, so patching one name in this module reaches every engine and every protocol that -reads it. The proxy addresses are the exception: :func:`http_proxy` and -:func:`https_proxy` read their variables on every call. +reads it. + +:func:`read_limit` and :func:`read_flag` read and validate the variables, and +are public so that a module keeping variables of its own validates them the same +way: :mod:`pcapkit.vendor.default` reads the crawlers' variables with them, and +documents those variables itself. Besides the standard library, this module imports :mod:`pcapkit.utilities.warnings` and nothing else from :mod:`pcapkit`, which is @@ -50,8 +53,8 @@ 'FRAME_LAYER_LIMIT', 'EXTENSION_HEADER_LIMIT', 'IPV6_TCP_WARNED_LIMIT', 'FALLBACK_LIMIT', 'DETECT_CACHE_SIZE', 'READ_CHUNK', 'MAX_ZERO_PAD_LENGTH', 'MAX_ZERO_PAD_SHORTFALL', 'ZERO_PAD_BUDGET_RATIO', - # vendor crawlers - 'VENDOR_RETRY', 'CI_MODE', 'http_proxy', 'https_proxy', + # readers + 'read_limit', 'read_flag', ] #: Strings a boolean variable may take, case-insensitively. @@ -65,10 +68,14 @@ "frame may hit Python's recursion limit and raise RecursionError (#1610)") -def _read_limit(name: 'str', default: 'int', *, minimum: 'int' = 0, +def read_limit(name: 'str', default: 'int', *, minimum: 'int' = 0, risk: 'Optional[str]' = None) -> 'int': """Read the integer environment variable ``PCAPKIT_``. + A value that is not an integer, or is below ``minimum``, is reported with a + :exc:`~pcapkit.utilities.warnings.ConfigWarning` naming the variable and the + value, and ``default`` is used instead. + Args: name: Variable name, without the ``PCAPKIT_`` prefix. default: Value used when the variable is unset or invalid. @@ -80,6 +87,8 @@ def _read_limit(name: 'str', default: 'int', *, minimum: 'int' = 0, Returns: The value of the variable, or ``default``. + .. versionadded:: 1.5.0 + """ variable = f'PCAPKIT_{name}' text = os.environ.get(variable) @@ -103,9 +112,15 @@ def _read_limit(name: 'str', default: 'int', *, minimum: 'int' = 0, return value -def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': +def read_flag(name: 'str', default: 'bool' = False) -> 'bool': """Read the boolean environment variable ``PCAPKIT_``. + The value is read with surrounding whitespace stripped and + case-insensitively, and is one of ``1``, ``yes``, ``true`` or ``on``, or of + ``0``, ``no``, ``false`` or ``off``. Any other value is reported with a + :exc:`~pcapkit.utilities.warnings.ConfigWarning` naming the variable and the + value, and ``default`` is used instead. + Args: name: Variable name, without the ``PCAPKIT_`` prefix. default: Value used when the variable is unset or invalid. @@ -113,6 +128,8 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': Returns: The value of the variable, or ``default``. + .. versionadded:: 1.5.0 + """ variable = f'PCAPKIT_{name}' text = os.environ.get(variable) @@ -153,7 +170,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.protocols.protocol.FRAME_LAYER_LIMIT`` (:issue:`1726`). -FRAME_LAYER_LIMIT = _read_limit('FRAME_LAYER_LIMIT', 64, risk=_RECURSION_RISK) +FRAME_LAYER_LIMIT = read_limit('FRAME_LAYER_LIMIT', 64, risk=_RECURSION_RISK) #: int: Most IPv6 extension headers dissected in one chain, i.e. one after #: another with no other layer between them (:issue:`1604`), @@ -185,7 +202,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.protocols.internet.internet.EXTENSION_HEADER_LIMIT`` (:issue:`1726`). -EXTENSION_HEADER_LIMIT = _read_limit('EXTENSION_HEADER_LIMIT', 32, risk=_RECURSION_RISK) +EXTENSION_HEADER_LIMIT = read_limit('EXTENSION_HEADER_LIMIT', 32, risk=_RECURSION_RISK) #: int: How many captures the ``pypcapfile`` engine remembers having warned #: about for TCP over IPv6, :envvar:`PCAPKIT_IPV6_TCP_WARNED_LIMIT`. @@ -197,7 +214,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.toolkit.pypcapfile.IPV6_TCP_WARNED_LIMIT`` (:issue:`1726`). -IPV6_TCP_WARNED_LIMIT = _read_limit('IPV6_TCP_WARNED_LIMIT', 32, risk=( +IPV6_TCP_WARNED_LIMIT = read_limit('IPV6_TCP_WARNED_LIMIT', 32, risk=( 'each capture remembered pins one of its packets in memory')) #: int: Most objects :meth:`DictDumper._append_fallback @@ -212,7 +229,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.dumpkit.common.FALLBACK_LIMIT`` (:issue:`1726`). -FALLBACK_LIMIT = _read_limit('FALLBACK_LIMIT', 10_000, risk=( +FALLBACK_LIMIT = read_limit('FALLBACK_LIMIT', 10_000, risk=( 'an object that expands into a large or endless object graph is written out ' 'in full, which takes memory and time without bound')) @@ -227,7 +244,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.utilities.chardet.DETECT_CACHE_SIZE`` (:issue:`1726`). -DETECT_CACHE_SIZE = _read_limit('DETECT_CACHE_SIZE', 1024, risk=( +DETECT_CACHE_SIZE = read_limit('DETECT_CACHE_SIZE', 1024, risk=( 'the cache keeps every bytestring it was asked about, so a capture full of ' 'never-repeating text is retained in memory')) @@ -241,7 +258,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.corekit.io._READ_CHUNK`` (:issue:`1726`). -READ_CHUNK = _read_limit('READ_CHUNK', 1 << 20, minimum=1, risk=( +READ_CHUNK = read_limit('READ_CHUNK', 1 << 20, minimum=1, risk=( 'a bogus record length allocates up to that many octets in one read')) #: int: Ceiling on the zero-padding :meth:`FieldBase.unpack @@ -279,7 +296,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.corekit.fields.field._MAX_ZERO_PAD_LENGTH`` (:issue:`1726`). -MAX_ZERO_PAD_LENGTH = _read_limit('MAX_ZERO_PAD_LENGTH', 0x40_000, risk=( +MAX_ZERO_PAD_LENGTH = read_limit('MAX_ZERO_PAD_LENGTH', 0x40_000, risk=( 'a hostile capture can declare a field that long and have it zero-filled in memory')) #: int: Shortfall :meth:`FieldBase.unpack @@ -330,7 +347,7 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.corekit.fields.field._MAX_ZERO_PAD_SHORTFALL`` (:issue:`1726`). -MAX_ZERO_PAD_SHORTFALL = _read_limit('MAX_ZERO_PAD_SHORTFALL', 0x10_000, risk=( +MAX_ZERO_PAD_SHORTFALL = read_limit('MAX_ZERO_PAD_SHORTFALL', 0x10_000, risk=( 'shortfalls up to that size are zero-filled without being charged to the running budget, ' 'so a capture of many lying lengths can amplify into that much memory each')) @@ -360,60 +377,6 @@ def _read_flag(name: 'str', default: 'bool' = False) -> 'bool': #: .. versionadded:: 1.5.0 #: #: Moved from ``pcapkit.corekit.fields.field._ZERO_PAD_BUDGET_RATIO`` (:issue:`1726`). -ZERO_PAD_BUDGET_RATIO = _read_limit('ZERO_PAD_BUDGET_RATIO', 0x10, risk=( +ZERO_PAD_BUDGET_RATIO = read_limit('ZERO_PAD_BUDGET_RATIO', 0x10, risk=( 'a capture whose blocks all lie about their lengths can amplify each octet read ' 'into that many zero octets in memory')) - -############################################################################### -# Vendor Crawlers -############################################################################### - -#: int: How many times a :mod:`pcapkit.vendor` crawler retries a failed request, -#: :envvar:`PCAPKIT_VENDOR_RETRY`. -#: -#: .. versionadded:: 1.5.0 -#: -#: Moved from ``pcapkit.vendor.default.MAX_RETRY`` (:issue:`1726`), which -#: crashed the import on a value that was not an integer, and read ``0`` as -#: ``1``. -VENDOR_RETRY = _read_limit('VENDOR_RETRY', 5, minimum=1) - -#: bool: Whether a :mod:`pcapkit.vendor` crawler runs in CI, where a failed -#: fetch fails the run instead of prompting, :envvar:`PCAPKIT_CI_MODE`. -#: -#: .. versionadded:: 1.5.0 -#: -#: Moved from ``pcapkit.vendor.default.CI_MODE`` (:issue:`1726`). -CI_MODE = _read_flag('CI_MODE') - - -def http_proxy() -> 'Optional[str]': - """HTTP proxy address for the :mod:`pcapkit.vendor` crawlers. - - Unlike the limits, :envvar:`PCAPKIT_HTTP_PROXY` is read on every call, as - :func:`~pcapkit.vendor.default.get_proxies` always has, so a proxy set after - :mod:`pcapkit` is imported is still honoured. - - Returns: - The value of :envvar:`PCAPKIT_HTTP_PROXY`, or :data:`None` if unset. - - .. versionadded:: 1.5.0 - - """ - return os.environ.get('PCAPKIT_HTTP_PROXY') - - -def https_proxy() -> 'Optional[str]': - """HTTPS proxy address for the :mod:`pcapkit.vendor` crawlers. - - Unlike the limits, :envvar:`PCAPKIT_HTTPS_PROXY` is read on every call, as - :func:`~pcapkit.vendor.default.get_proxies` always has, so a proxy set after - :mod:`pcapkit` is imported is still honoured. - - Returns: - The value of :envvar:`PCAPKIT_HTTPS_PROXY`, or :data:`None` if unset. - - .. versionadded:: 1.5.0 - - """ - return os.environ.get('PCAPKIT_HTTPS_PROXY') diff --git a/pcapkit/utilities/logging.py b/pcapkit/utilities/logging.py index ed18735e5..1bb86cecb 100644 --- a/pcapkit/utilities/logging.py +++ b/pcapkit/utilities/logging.py @@ -75,7 +75,7 @@ def _read_flag(variable: 'str') -> 'bool': """Read the boolean environment variable ``variable``, off by default. The value is read with surrounding whitespace stripped and - case-insensitively, as :func:`pcapkit.utilities.config._read_flag` reads + case-insensitively, as :func:`pcapkit.utilities.config.read_flag` reads one. A value that is not then one of :data:`BOOLEAN_STATES` is taken as off and recorded in :data:`_BAD_FLAGS`. diff --git a/pcapkit/vendor/default.py b/pcapkit/vendor/default.py index 838e2f33d..772078738 100644 --- a/pcapkit/vendor/default.py +++ b/pcapkit/vendor/default.py @@ -7,17 +7,6 @@ :mod:`pcapkit.vendor.default` contains :class:`~pcapkit.vendor.default.Vendor` only, which is the base meta class for all vendor crawlers. -.. versionremoved:: 1.5.0 - - Moved to :mod:`pcapkit.utilities.config` (:issue:`1726`): - - * ``pcapkit.vendor.default.MAX_RETRY``, now - :data:`pcapkit.utilities.config.VENDOR_RETRY`, set with - :envvar:`PCAPKIT_VENDOR_RETRY`; - * ``pcapkit.vendor.default.CI_MODE``, now - :data:`pcapkit.utilities.config.CI_MODE`, set with - :envvar:`PCAPKIT_CI_MODE`. - """ import abc import collections @@ -47,6 +36,30 @@ __all__ = ['Vendor'] +#: int: How many times :meth:`Vendor._request` asks for a crawler's page, both +#: directly and then through the proxies of :func:`get_proxies`, before giving +#: up, :envvar:`PCAPKIT_VENDOR_RETRY`. +#: +#: .. versionchanged:: 1.5.0 +#: +#: Read with :func:`~pcapkit.utilities.config.read_limit` (:issue:`1726`). A +#: value that is not an integer of at least ``1`` warns with +#: :exc:`~pcapkit.utilities.warnings.ConfigWarning` and falls back to ``5``, +#: where one that was not an integer crashed the import, and ``0`` was read +#: as ``1``. +MAX_RETRY = config.read_limit('VENDOR_RETRY', 5, minimum=1) + +#: bool: Whether the crawlers run in CI, where a failed fetch fails the run +#: instead of prompting, :envvar:`PCAPKIT_CI_MODE`. +#: +#: .. versionchanged:: 1.5.0 +#: +#: Read with :func:`~pcapkit.utilities.config.read_flag` (:issue:`1726`). A +#: value it does not accept warns with +#: :exc:`~pcapkit.utilities.warnings.ConfigWarning` and falls back to off, +#: where it was taken as off silently. +CI_MODE = config.read_flag('CI_MODE') + #: Distribution name of this package, i.e. the name its metadata is registered #: under, which is not the import name (:mod:`pcapkit`). DISTRIBUTION = 'pypcapkit' @@ -102,10 +115,8 @@ def get_proxies() -> 'dict[str, str]': """Get proxy for blocked sites. The function will read :envvar:`PCAPKIT_HTTP_PROXY` and - :envvar:`PCAPKIT_HTTPS_PROXY`, if any, on every call, through - :func:`~pcapkit.utilities.config.http_proxy` and - :func:`~pcapkit.utilities.config.https_proxy`, for the proxy settings of - |requests|_. + :envvar:`PCAPKIT_HTTPS_PROXY`, if any, on every call, for the proxy + settings of |requests|_. .. |requests| replace:: ``requests`` .. _requests: https://requests.readthedocs.io @@ -114,8 +125,8 @@ def get_proxies() -> 'dict[str, str]': Proxy settings for |requests|_. """ - HTTP_PROXY = config.http_proxy() - HTTPS_PROXY = config.https_proxy() + HTTP_PROXY = os.getenv('PCAPKIT_HTTP_PROXY') + HTTPS_PROXY = os.getenv('PCAPKIT_HTTPS_PROXY') PROXIES = {} # type: dict[str, str] if HTTP_PROXY is not None: PROXIES['http'] = HTTP_PROXY @@ -607,19 +618,19 @@ def _request(self) -> 'list[str]': # NOTE: both branches below send this. Wikimedia rejects ``requests``' # default agent with HTTP 403, so a crawler without it fetches 126 bytes - # of robot-policy text and retries config.VENDOR_RETRY times against a refusal + # of robot-policy text and retries MAX_RETRY times against a refusal # that no amount of retrying will lift; see #518. headers = {'User-Agent': get_user_agent()} try: counter = 1 while True: - if counter > config.VENDOR_RETRY: + if counter > MAX_RETRY: raise requests.exceptions.RequestException page = requests.get(self.LINK, headers=headers) # nosec: B113 if not page.ok or not page.text: - warn(f'Connection failed; retry for {counter}/{config.VENDOR_RETRY}...', + warn(f'Connection failed; retry for {counter}/{MAX_RETRY}...', VendorRequestWarning, stacklevel=2) counter += 1 @@ -636,19 +647,19 @@ def _request(self) -> 'list[str]': counter = 1 while True: - if counter > config.VENDOR_RETRY: + if counter > MAX_RETRY: raise page = requests.get(self.LINK, headers=headers, proxies=proxies) # nosec: B113 if not page.ok or not page.text: - warn(f'Connection failed; retry with proxy for {counter}/{config.VENDOR_RETRY}...', + warn(f'Connection failed; retry with proxy for {counter}/{MAX_RETRY}...', VendorRequestWarning, stacklevel=2) counter += 1 continue break except requests.RequestException as error: - if config.CI_MODE: + if CI_MODE: warn('Connection failed; exit on CI mode...', VendorRequestWarning, stacklevel=2) raise @@ -657,7 +668,7 @@ def _request(self) -> 'list[str]': # NOTE: manual intervention needs an operator, and a process # with no terminal has none -- nobody to read the instructions # below, nobody to save the page, so the wait it ends in could - # only ever time out the whole run. Fail exactly as config.CI_MODE + # only ever time out the whole run. Fail exactly as CI_MODE # does rather than prompting into the void; see #522. warn('Connection failed; exit as stdin is not interactive...', VendorRequestWarning, stacklevel=2) diff --git a/pcapkit/vendor/pcapng/block_type.py b/pcapkit/vendor/pcapng/block_type.py index 9bc1bd5f3..bb9009ab9 100644 --- a/pcapkit/vendor/pcapng/block_type.py +++ b/pcapkit/vendor/pcapng/block_type.py @@ -34,7 +34,7 @@ # The dead ``-02`` URL, # https://www.ietf.org/staging/draft-tuexen-opsawg-pcapng-02.html, serves a # 77968-byte HTML error page rather than a clean refusal, so ``Vendor._request`` -# rejects it on ``page.ok`` and retries config.VENDOR_RETRY times against a page that will +# rejects it on ``page.ok`` and retries MAX_RETRY times against a page that will # never come back. On ``-02`` the registries are ASCII art inside ``
`` and
 # the only HTML ```` is the running-header one, so
 # ``soup.select('table#table-9')`` below finds nothing and raises ``IndexError``.
diff --git a/tests/utilities/test_config_unit.py b/tests/utilities/test_config_unit.py
index 5ff7826c9..7f47b7ad8 100644
--- a/tests/utilities/test_config_unit.py
+++ b/tests/utilities/test_config_unit.py
@@ -8,12 +8,12 @@
 :mod:`pcapkit.utilities.config`, every reader looks it up there at call time,
 and the old import paths are gone. A bad value warns and falls back to the
 default; ``-1`` lifts a limit, and that or any value above the default warns of
-the risk.
+the risk. The :mod:`pcapkit.vendor` crawlers' variables are not held here:
+``tests/vendor/test_vendor_variables_1726_unit.py`` covers them.
 
 """
 
 import ast
-import collections
 import contextlib
 import importlib
 import io
@@ -51,13 +51,18 @@
     'pcapkit.corekit.io': ['_READ_CHUNK'],
     'pcapkit.corekit.fields.field': ['_MAX_ZERO_PAD_LENGTH', '_MAX_ZERO_PAD_SHORTFALL',
                                      '_ZERO_PAD_BUDGET_RATIO'],
-    'pcapkit.vendor.default': ['MAX_RETRY', 'CI_MODE'],
 }
 
-#: Every integer variable :mod:`pcapkit.utilities.config` declares, as
-#: ``(name, default, minimum, a phrase of its risk)``, the risk :data:`None` where
-#: ``-1`` is refused. Written out rather than read back, so that a changed name,
-#: default, minimum or risk fails.
+#: What :mod:`pcapkit.utilities.config` held for the :mod:`pcapkit.vendor`
+#: crawlers before they kept their own variables again (:issue:`1726`).
+VENDOR_NAMES = ('VENDOR_RETRY', 'CI_MODE', 'http_proxy', 'https_proxy')
+
+#: The public readers of :mod:`pcapkit.utilities.config`, which are not variables.
+READERS = frozenset({'read_limit', 'read_flag'})
+
+#: Every variable :mod:`pcapkit.utilities.config` declares, as
+#: ``(name, default, minimum, a phrase of its risk)``. Written out rather than
+#: read back, so that a changed name, default, minimum or risk fails.
 DECLARED = (
     ('FRAME_LAYER_LIMIT', 64, 0, 'RecursionError (#1610)'),
     ('EXTENSION_HEADER_LIMIT', 32, 0, 'RecursionError (#1610)'),
@@ -68,7 +73,6 @@
     ('MAX_ZERO_PAD_LENGTH', 262_144, 0, 'declare a field that long'),
     ('MAX_ZERO_PAD_SHORTFALL', 65_536, 0, 'without being charged to the running budget'),
     ('ZERO_PAD_BUDGET_RATIO', 16, 0, 'amplify each octet read'),
-    ('VENDOR_RETRY', 5, 1, None),
 )
 
 
@@ -105,7 +109,7 @@ def read(self, text: 'str | None', default: int = 64, **kwargs: object) -> 'tupl
         values = {} if text is None else {'TEST': text}
         with environ(**values), warnings.catch_warnings(record=True) as caught:
             warnings.simplefilter('always')
-            value = self.config._read_limit('TEST', default, **kwargs)  # pylint: disable=protected-access
+            value = self.config.read_limit('TEST', default, **kwargs)
         self.assertTrue(all(issubclass(item.category, self.ConfigWarning) for item in caught), caught)
         return value, [str(item.message) for item in caught]
 
@@ -138,6 +142,12 @@ def test_every_message_names_the_variable_and_the_value(self) -> None:
                 self.assertEqual(len(messages), 1)
                 self.assertIn(f'PCAPKIT_TEST={text!r}', messages[0])
 
+    def test_the_readers_are_public(self) -> None:
+        self.assertLessEqual(READERS, set(self.config.__all__))
+        for name in READERS:
+            with self.subTest(name=name):
+                self.assertFalse(hasattr(self.config, f'_{name}'))
+
     def test_without_a_risk_minus_one_is_refused(self) -> None:
         value, messages = self.read('-1', default=5, minimum=1)
         self.assertEqual((value, len(messages)), (5, 1))
@@ -145,10 +155,10 @@ def test_without_a_risk_minus_one_is_refused(self) -> None:
 
     def test_a_flag(self) -> None:
         with environ(TEST='Yes'):
-            self.assertIs(self.config._read_flag('TEST'), True)  # pylint: disable=protected-access
+            self.assertIs(self.config.read_flag('TEST'), True)
         with environ(TEST='maybe'), warnings.catch_warnings(record=True) as caught:
             warnings.simplefilter('always')
-            self.assertIs(self.config._read_flag('TEST'), False)  # pylint: disable=protected-access
+            self.assertIs(self.config.read_flag('TEST'), False)
         self.assertEqual([item.category for item in caught], [self.ConfigWarning])
 
     def test_both_flag_readers_strip_whitespace(self) -> None:
@@ -158,7 +168,7 @@ def test_both_flag_readers_strip_whitespace(self) -> None:
         for text, expected in ((' yes ', True), ('\tOff\n', False)):
             with self.subTest(text=text), environ(TEST=text), warnings.catch_warnings(record=True) as caught:
                 warnings.simplefilter('always')
-                self.assertIs(self.config._read_flag('TEST'), expected)  # pylint: disable=protected-access
+                self.assertIs(self.config.read_flag('TEST'), expected)
                 self.assertIs(logging._read_flag('PCAPKIT_TEST'), expected)  # pylint: disable=protected-access
             self.assertEqual(caught, [])
             self.assertEqual(logging._BAD_FLAGS, bad)  # pylint: disable=protected-access
@@ -179,14 +189,14 @@ def test_the_frame_layer_limit_names_the_recursion_risk(self) -> None:
         self.assertEqual(self.config.FRAME_LAYER_LIMIT, 64)
 
     def test_a_bad_value_does_not_break_the_import(self) -> None:
-        with environ(VENDOR_RETRY='lots', FALLBACK_LIMIT='-7'), \
+        with environ(READ_CHUNK='lots', FALLBACK_LIMIT='-7'), \
                 warnings.catch_warnings(record=True) as caught:
             warnings.simplefilter('always')
             try:
                 importlib.reload(self.config)
-                self.assertEqual((self.config.VENDOR_RETRY, self.config.FALLBACK_LIMIT), (5, 10_000))
+                self.assertEqual((self.config.READ_CHUNK, self.config.FALLBACK_LIMIT), (1 << 20, 10_000))
             finally:
-                os.environ.pop('PCAPKIT_VENDOR_RETRY')
+                os.environ.pop('PCAPKIT_READ_CHUNK')
                 os.environ.pop('PCAPKIT_FALLBACK_LIMIT')
                 importlib.reload(self.config)
         self.assertEqual(len([item for item in caught if issubclass(item.category, self.ConfigWarning)]), 2)
@@ -220,27 +230,23 @@ def load(self, name: str, text: 'str | None') -> 'tuple[object, list[str]]':
                 importlib.reload(self.config)
 
     def test_the_table_names_every_variable(self) -> None:
-        names = {row[0] for row in DECLARED} | {'CI_MODE'}
-        self.assertEqual(names, set(self.config.__all__) - {'http_proxy', 'https_proxy'})
+        names = {row[0] for row in DECLARED}
+        self.assertEqual(names, set(self.config.__all__) - READERS)
 
     def test_each_variable_is_read_by_its_own_name(self) -> None:
         for name, default, _, _ in DECLARED:
             with self.subTest(name=name):
                 self.assertEqual(self.load(name, str(default - 1)), (default - 1, []))
-        with self.subTest(name='CI_MODE'):
-            self.assertEqual(self.load('CI_MODE', 'yes'), (True, []))
 
     def test_each_default(self) -> None:
         for name, default, _, _ in DECLARED:
             with self.subTest(name=name):
                 self.assertEqual(self.load(name, None), (default, []))
-        with self.subTest(name='CI_MODE'):
-            self.assertEqual(self.load('CI_MODE', None), (False, []))
 
     def test_below_the_minimum_falls_back(self) -> None:
-        for name, default, minimum, risk in DECLARED:
-            # Where -1 means no limit, the first value refused is -2.
-            below = -2 if risk is not None and minimum == 0 else minimum - 1
+        for name, default, minimum, _ in DECLARED:
+            # -1 means no limit, so the first value refused is -2.
+            below = -2 if minimum == 0 else minimum - 1
             with self.subTest(name=name):
                 self.assertEqual(self.load(name, str(minimum)), (minimum, []))
                 value, messages = self.load(name, str(below))
@@ -252,11 +258,6 @@ def test_below_the_minimum_falls_back(self) -> None:
     def test_minus_one_and_above_the_default_warn_of_the_risk(self) -> None:
         for name, default, _, risk in DECLARED:
             with self.subTest(name=name):
-                if risk is None:
-                    value, messages = self.load(name, '-1')
-                    self.assertEqual((value, len(messages)), (default, 1), messages)
-                    self.assertEqual(self.load(name, str(default + 1)), (default + 1, []))
-                    continue
                 for text, lifted in (('-1', 'lifts the limit'),
                                      (str(default + 1), f'is above the default of {default}')):
                     value, messages = self.load(name, text)
@@ -299,10 +300,22 @@ def test_every_variable_is_documented(self) -> None:
         from pcapkit.utilities import config
 
         docs = (PACKAGE.parent / 'docs' / 'source' / 'pcapkit' / 'utilities' / 'config.rst').read_text('utf-8')
-        # A limit is named after its variable; a proxy accessor is its lowercase.
-        for name in config.__all__:
+        # A limit is named after its variable.
+        for name in set(config.__all__) - READERS:
+            with self.subTest(name=name):
+                self.assertIn(f'.. envvar:: PCAPKIT_{name}\n', docs)
+        for name in READERS:
             with self.subTest(name=name):
-                self.assertIn(f'.. envvar:: PCAPKIT_{name.upper()}\n', docs)
+                self.assertIn(f'.. autofunction:: pcapkit.utilities.config.{name}\n', docs)
+
+    def test_the_vendor_names_are_not_here(self) -> None:
+        # #1726: the crawlers keep their variables in pcapkit.vendor.default.
+        from pcapkit.utilities import config
+
+        for name in VENDOR_NAMES:
+            with self.subTest(name=name):
+                self.assertFalse(hasattr(config, name))
+                self.assertNotIn(name, config.__all__)
 
 
 class ReaderTests(unittest.TestCase):
@@ -524,78 +537,6 @@ def __init__(self, **kwargs: object) -> None:
             with mock.patch.object(self.config, 'FALLBACK_LIMIT', -1):
                 make_dumper(dictdumper.JSON)(path)({'node': node}, name='x')
 
-    def fetch(self, retry: int, proxies: 'dict[str, str]') -> 'tuple[list[object], list[str]]':
-        """The proxies of every request a failing crawl makes, and its retry notices.
-
-        ``VENDOR_RETRY`` is patched to ``retry``.
-
-        """
-        import requests
-
-        from pcapkit.vendor import default
-
-        class Crawler(default.Vendor):
-            FLAG = 'isinstance(value, int)'
-            LINK = 'https://example.invalid/registry'
-
-            def count(self, data: object) -> 'collections.Counter[str]':
-                return collections.Counter()
-
-            def process(self, data: object) -> 'tuple[list[str], list[str]]':
-                return [], []
-
-        fetched = []  # type: list[object]
-
-        def fail(url: object, **kwargs: object) -> object:
-            fetched.append(kwargs.get('proxies'))
-            return mock.Mock(ok=False, text='')
-
-        with mock.patch.object(requests, 'get', fail), \
-                mock.patch.object(default, 'get_proxies', return_value=proxies), \
-                mock.patch.object(self.config, 'CI_MODE', True), \
-                mock.patch.object(self.config, 'VENDOR_RETRY', retry), \
-                warnings.catch_warnings(record=True) as caught, \
-                self.assertRaises(requests.RequestException):
-            warnings.simplefilter('always')
-            Crawler.__new__(Crawler)._request()  # pylint: disable=protected-access
-        notices = [str(item.message) for item in caught]
-        return fetched, [text for text in notices if text.startswith((
-            'Connection failed; retry for ', 'Connection failed; retry with proxy for '))]
-
-    def test_vendor_retry_in_vendor(self) -> None:
-        for retry in (2, 3):
-            with self.subTest(retry=retry):
-                fetched, notices = self.fetch(retry, {})
-                self.assertEqual(fetched, [None] * retry)
-                # The notice counts against the configured retry, not the default 5.
-                self.assertEqual(notices, [f'Connection failed; retry for {count}/{retry}...'
-                                           for count in range(1, retry + 1)])
-
-    def test_vendor_retry_in_the_proxy_loop(self) -> None:
-        proxies = {'http': 'http://proxy.invalid'}
-        for retry in (2, 3):
-            with self.subTest(retry=retry):
-                fetched, notices = self.fetch(retry, proxies)
-                self.assertEqual(fetched, [None] * retry + [proxies] * retry)
-                self.assertEqual(notices, [f'Connection failed; retry for {count}/{retry}...'
-                                           for count in range(1, retry + 1)]
-                                 + [f'Connection failed; retry with proxy for {count}/{retry}...'
-                                    for count in range(1, retry + 1)])
-
-    def test_proxies_are_read_at_call_time(self) -> None:
-        from pcapkit.vendor import default
-
-        # Set after the import, as #1726 keeps it: the variables are read on
-        # every call, as get_proxies always read them.
-        with mock.patch.dict(os.environ, {'PCAPKIT_HTTP_PROXY': 'http://late'}):
-            os.environ.pop('PCAPKIT_HTTPS_PROXY', None)
-            self.assertEqual(self.config.http_proxy(), 'http://late')
-            self.assertIsNone(self.config.https_proxy())
-            self.assertEqual(default.get_proxies(), {'http': 'http://late'})
-        with mock.patch.dict(os.environ, {'PCAPKIT_HTTPS_PROXY': 'http://later'}):
-            os.environ.pop('PCAPKIT_HTTP_PROXY', None)
-            self.assertEqual(default.get_proxies(), {'https': 'http://later'})
-
 
 def run(code: str, **values: str) -> 'subprocess.CompletedProcess[str]':
     """Run ``code`` in a fresh interpreter from the repository root, with ``PCAPKIT_`` set."""
diff --git a/tests/vendor/test_request_prompt_unit.py b/tests/vendor/test_request_prompt_unit.py
index 88b65cf79..51f6abe2c 100644
--- a/tests/vendor/test_request_prompt_unit.py
+++ b/tests/vendor/test_request_prompt_unit.py
@@ -376,7 +376,7 @@ def fake_open(link: 'Any' = None, *args: 'Any', **kwargs: 'Any') -> 'bool':
         with contextlib.ExitStack() as stack:
             stack.enter_context(mock.patch.object(self.requests, 'get', fake_get))
             stack.enter_context(mock.patch.object(self.default, 'get_proxies', return_value={}))
-            stack.enter_context(mock.patch.object(self.default.config, 'CI_MODE', ci_mode))
+            stack.enter_context(mock.patch.object(self.default, 'CI_MODE', ci_mode))
             stack.enter_context(mock.patch.object(self.default, 'stdin_is_interactive',
                                                   return_value=interactive))
             stack.enter_context(mock.patch.object(self.default.webbrowser, 'open', fake_open))
diff --git a/tests/vendor/test_user_agent_unit.py b/tests/vendor/test_user_agent_unit.py
index f07c08e41..7725a8d14 100644
--- a/tests/vendor/test_user_agent_unit.py
+++ b/tests/vendor/test_user_agent_unit.py
@@ -7,7 +7,7 @@
 agent outright -- measured 2026-09-19 as HTTP 403 and 126 bytes of robot-policy
 text, against 200 for a descriptive agent, reproduced on four separate articles
 with nothing but the header changing -- so four crawlers could not fetch their
-registry at all, and burned ``config.VENDOR_RETRY`` attempts against a refusal that no
+registry at all, and burned ``MAX_RETRY`` attempts against a refusal that no
 amount of retrying lifts.
 
 .. |requests| replace:: ``requests``
diff --git a/tests/vendor/test_vendor_variables_1726_unit.py b/tests/vendor/test_vendor_variables_1726_unit.py
new file mode 100644
index 000000000..55980b44a
--- /dev/null
+++ b/tests/vendor/test_vendor_variables_1726_unit.py
@@ -0,0 +1,235 @@
+# -*- coding: utf-8 -*-
+"""The :mod:`pcapkit.vendor` crawlers read their own variables.
+
+GitHub issue #1726: #1743 moved ``pcapkit.vendor.default.MAX_RETRY`` and
+``CI_MODE``, and the proxy lookups, into :mod:`pcapkit.utilities.config`; the
+issue then settled that the crawlers keep their variables in
+:mod:`pcapkit.vendor`. So :mod:`pcapkit.vendor.default` reads
+:envvar:`PCAPKIT_VENDOR_RETRY` and :envvar:`PCAPKIT_CI_MODE` itself, through the
+public :func:`~pcapkit.utilities.config.read_limit` and
+:func:`~pcapkit.utilities.config.read_flag`, so a bad value still warns with a
+:exc:`~pcapkit.utilities.warnings.ConfigWarning` and falls back; and
+:func:`~pcapkit.vendor.default.get_proxies` reads the proxy variables itself, on
+every call.
+
+Nothing here makes a network call: ``requests.get`` is replaced for the whole of
+each case. The suite is unit-tier (see :mod:`tests._tiers`): it reads no capture.
+
+"""
+from __future__ import annotations
+
+import ast
+import collections
+import importlib
+import importlib.util
+import os
+import pathlib
+import unittest
+import warnings
+from unittest import mock
+
+from tests._support import reimport_once_per_class
+
+#: Repository root, i.e. the grandparent of the directory holding this file.
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+
+#: The module under test, read as text.
+SOURCE = ROOT / 'pcapkit' / 'vendor' / 'default.py'
+
+#: Every distribution importing :mod:`pcapkit.vendor` needs, as
+#: :file:`tests/vendor/test_user_agent_unit.py` explains.
+VENDOR_DEPS = ('requests', 'bs4', 'html5lib')
+
+#: Whether the crawlers are importable at all.
+HAS_VENDOR_DEPS = all(importlib.util.find_spec(name) is not None for name in VENDOR_DEPS)
+
+#: The crawlers' variables, all documented with :mod:`pcapkit.vendor.default`.
+VARIABLES = ('PCAPKIT_VENDOR_RETRY', 'PCAPKIT_CI_MODE', 'PCAPKIT_HTTP_PROXY', 'PCAPKIT_HTTPS_PROXY')
+
+
+class _VendorCase(unittest.TestCase):
+    """A fresh import of :mod:`pcapkit.vendor.default` from this checkout."""
+
+    def setUp(self) -> None:
+        reimport_once_per_class(self)
+
+        import pcapkit.vendor.default as default
+        from pcapkit.utilities.warnings import ConfigWarning
+
+        resolved = pathlib.Path(default.__file__).resolve()
+        if ROOT not in resolved.parents:
+            self.skipTest(f'{default.__name__} was imported from {resolved}, which is outside {ROOT}')
+        self.default = default
+        self.ConfigWarning = ConfigWarning
+
+
+@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})')
+class DeclarationTests(_VendorCase):
+    """``MAX_RETRY`` and ``CI_MODE``: each one's variable, default and minimum."""
+
+    def load(self, name: str, text: 'str | None') -> 'tuple[object, list[str]]':
+        """``default.`` with only its variable set, to ``text``, and the ConfigWarnings given."""
+        variable = {'MAX_RETRY': 'PCAPKIT_VENDOR_RETRY', 'CI_MODE': 'PCAPKIT_CI_MODE'}[name]
+        env = {key: value for key, value in os.environ.items() if not key.startswith('PCAPKIT_')}
+        if text is not None:
+            env[variable] = text
+        try:
+            with mock.patch.dict(os.environ, env, clear=True), warnings.catch_warnings(record=True) as caught:
+                warnings.simplefilter('always')
+                importlib.reload(self.default)
+            return getattr(self.default, name), [str(item.message) for item in caught
+                                                 if issubclass(item.category, self.ConfigWarning)]
+        finally:
+            with warnings.catch_warnings():
+                warnings.simplefilter('ignore')
+                importlib.reload(self.default)
+
+    def test_the_names_are_back(self) -> None:
+        self.assertIsInstance(self.default.MAX_RETRY, int)
+        self.assertIsInstance(self.default.CI_MODE, bool)
+
+    def test_max_retry_reads_pcapkit_vendor_retry(self) -> None:
+        self.assertEqual(self.load('MAX_RETRY', '4'), (4, []))
+        # Above the default is taken silently: there is no risk to report, and
+        # .github/workflows/cron-vendor.yml sets 10.
+        self.assertEqual(self.load('MAX_RETRY', '10'), (10, []))
+
+    def test_max_retry_defaults_to_five(self) -> None:
+        self.assertEqual(self.load('MAX_RETRY', None), (5, []))
+
+    def test_max_retry_takes_one_and_refuses_anything_below(self) -> None:
+        self.assertEqual(self.load('MAX_RETRY', '1'), (1, []))
+        # 0 was read as 1 before #1726, and -1 is no limit only where a limit
+        # has a risk to name; here both are refused, as is a non-integer, which
+        # crashed the import.
+        for text in ('0', '-1', 'lots'):
+            with self.subTest(text=text):
+                value, messages = self.load('MAX_RETRY', text)
+                self.assertEqual((value, len(messages)), (5, 1), messages)
+                self.assertEqual(messages[0], f'PCAPKIT_VENDOR_RETRY={text!r} is not an integer '
+                                              'of at least 1; using the default, 5')
+
+    def test_ci_mode_reads_pcapkit_ci_mode(self) -> None:
+        # 1 is what .github/workflows/cron-vendor.yml sets.
+        for text in ('1', 'yes', ' On '):
+            with self.subTest(text=text):
+                self.assertEqual(self.load('CI_MODE', text), (True, []))
+        self.assertEqual(self.load('CI_MODE', 'off'), (False, []))
+
+    def test_ci_mode_defaults_to_off(self) -> None:
+        self.assertEqual(self.load('CI_MODE', None), (False, []))
+
+    def test_a_bad_ci_mode_warns_and_falls_back_to_off(self) -> None:
+        value, messages = self.load('CI_MODE', 'maybe')
+        self.assertEqual((value, len(messages)), (False, 1), messages)
+        self.assertTrue(messages[0].startswith("PCAPKIT_CI_MODE='maybe' is not one of "), messages[0])
+        self.assertTrue(messages[0].endswith('; using the default, False'), messages[0])
+
+
+@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})')
+class ReaderTests(_VendorCase):
+    """``Vendor._request`` and ``get_proxies`` read the variables of this module."""
+
+    def fetch(self, retry: int, proxies: 'dict[str, str]') -> 'tuple[list[object], list[str]]':
+        """The proxies of every request a failing crawl makes, and its retry notices.
+
+        ``MAX_RETRY`` is patched to ``retry``, and ``CI_MODE`` on, so the crawl
+        fails rather than prompting once both loops are spent.
+
+        """
+        import requests
+
+        default = self.default
+
+        class Crawler(default.Vendor):
+            FLAG = 'isinstance(value, int)'
+            LINK = 'https://example.invalid/registry'
+
+            def count(self, data: object) -> 'collections.Counter[str]':
+                return collections.Counter()
+
+            def process(self, data: object) -> 'tuple[list[str], list[str]]':
+                return [], []
+
+        fetched = []  # type: list[object]
+
+        def fail(url: object, **kwargs: object) -> object:
+            fetched.append(kwargs.get('proxies'))
+            return mock.Mock(ok=False, text='')
+
+        with mock.patch.object(requests, 'get', fail), \
+                mock.patch.object(default, 'get_proxies', return_value=proxies), \
+                mock.patch.object(default, 'CI_MODE', True), \
+                mock.patch.object(default, 'MAX_RETRY', retry), \
+                warnings.catch_warnings(record=True) as caught, \
+                self.assertRaises(requests.RequestException):
+            warnings.simplefilter('always')
+            Crawler.__new__(Crawler)._request()  # pylint: disable=protected-access
+        notices = [str(item.message) for item in caught]
+        return fetched, [text for text in notices if text.startswith((
+            'Connection failed; retry for ', 'Connection failed; retry with proxy for '))]
+
+    def test_max_retry_in_the_direct_loop(self) -> None:
+        for retry in (2, 3):
+            with self.subTest(retry=retry):
+                fetched, notices = self.fetch(retry, {})
+                self.assertEqual(fetched, [None] * retry)
+                # The notice counts against the configured retry, not the default 5.
+                self.assertEqual(notices, [f'Connection failed; retry for {count}/{retry}...'
+                                           for count in range(1, retry + 1)])
+
+    def test_max_retry_in_the_proxy_loop(self) -> None:
+        proxies = {'http': 'http://proxy.invalid'}
+        for retry in (2, 3):
+            with self.subTest(retry=retry):
+                fetched, notices = self.fetch(retry, proxies)
+                self.assertEqual(fetched, [None] * retry + [proxies] * retry)
+                self.assertEqual(notices, [f'Connection failed; retry for {count}/{retry}...'
+                                           for count in range(1, retry + 1)]
+                                 + [f'Connection failed; retry with proxy for {count}/{retry}...'
+                                    for count in range(1, retry + 1)])
+
+    def test_proxies_are_read_on_every_call(self) -> None:
+        # Set after the import: the variables are read on every call, as
+        # get_proxies always read them.
+        with mock.patch.dict(os.environ, {'PCAPKIT_HTTP_PROXY': 'http://late'}):
+            os.environ.pop('PCAPKIT_HTTPS_PROXY', None)
+            self.assertEqual(self.default.get_proxies(), {'http': 'http://late'})
+        with mock.patch.dict(os.environ, {'PCAPKIT_HTTPS_PROXY': 'http://later'}):
+            os.environ.pop('PCAPKIT_HTTP_PROXY', None)
+            self.assertEqual(self.default.get_proxies(), {'https': 'http://later'})
+        with mock.patch.dict(os.environ, {'PCAPKIT_HTTP_PROXY': 'http://a', 'PCAPKIT_HTTPS_PROXY': 'http://b'}):
+            self.assertEqual(self.default.get_proxies(), {'http': 'http://a', 'https': 'http://b'})
+
+
+class LayoutTests(unittest.TestCase):
+    """Where the variables are read and documented; read as text, so no extra is needed."""
+
+    def test_only_the_public_readers_of_config_are_used(self) -> None:
+        used = {node.attr for node in ast.walk(ast.parse(SOURCE.read_text(encoding='utf-8')))
+                if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)
+                and node.value.id == 'config'}
+        self.assertEqual(used, {'read_limit', 'read_flag'})
+
+    def test_every_variable_is_documented_with_the_crawlers(self) -> None:
+        docs = ROOT / 'docs' / 'source' / 'pcapkit'
+        vendor = (docs / 'vendor' / 'default.rst').read_text(encoding='utf-8')
+        config = (docs / 'utilities' / 'config.rst').read_text(encoding='utf-8')
+        for variable in VARIABLES:
+            with self.subTest(variable=variable):
+                self.assertIn(f'.. envvar:: {variable}\n', vendor)
+                self.assertNotIn(f'.. envvar:: {variable}\n', config)
+                self.assertIn(f':envvar:`{variable}`', config)
+        for name in ('MAX_RETRY', 'CI_MODE'):
+            with self.subTest(name=name):
+                self.assertIn(f'.. autodata:: pcapkit.vendor.default.{name}\n', vendor)
+
+    def test_no_removal_notice_is_left(self) -> None:
+        docs = ROOT / 'docs' / 'source' / 'pcapkit' / 'vendor' / 'default.rst'
+        for path in (SOURCE, docs):
+            with self.subTest(path=path.name):
+                self.assertNotIn('versionremoved', path.read_text(encoding='utf-8'))
+
+
+if __name__ == '__main__':
+    unittest.main()