Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-Protego for openSUSE:Factory 
checked in at 2026-10-26 10:02:22
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-Protego (Old)
 and      /work/SRC/openSUSE:Factory/.python-Protego.new.2252680 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-Protego"

Mon Oct 26 10:02:22 2026 rev:11 rq:1392511 version:0.7.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-Protego/python-Protego.changes    
2026-07-07 21:04:03.796050809 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-Protego.new.2252680/python-Protego.changes   
    2026-10-26 10:03:48.489290392 +0100
@@ -1,0 +2,46 @@
+Sun Oct 25 19:08:35 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 0.7.0:
+  * Backward-incompatible: Protego.parse() now raises a more
+    suitable TypeError instead of a ValueError when content is
+    not a string.
+  * Added official support for Python 3.15.
+  * can_fetch() now allows /robots.txt whatever the rules say, as
+    required by RFC 9309.
+  * A group now applies to a user agent only if its product token
+    appears in that user agent at a token boundary, so that User-
+    agent: bot no longer applies to mybot. A product token within
+    a whole User-Agent header value, e.g. Mozilla/5.0
+    (compatible; mybot/1.0), still matches.
+  * Percent-encoding is now normalized in the query string and
+    the parameters of a URL, and not only in its path, so that a
+    rule matches whichever spelling either of them uses, e.g.
+    Disallow: /a?b=ツ now matches /a?b=%E3%83%84. The fragment is
+    now left out of matching altogether.
+  * Visit-time now also accepts a hyphen between the two times,
+    e.g. 0400-0845, and times with a single-digit hour, e.g. 400.
+  * Malformed Crawl-delay values, such as a negative or infinite
+    number, and Request-rate values with zero requests or zero
+    seconds, are now ignored, as other malformed values already
+    were.
+  * Improved the handling of invalid and misspelled lines.
+    Sitemap and Host lines are now read anywhere in the file,
+    including before any User-agent line, and no longer merge the
+    groups they sit between. A User-agent line without a value
+    still ends the preceding group. Lines written without a colon
+    are now salvaged for every directive, Visit-time included,
+    and with any whitespace as the separator. A line whose field
+    is not a known directive is no longer read as one.
+  * Fixed matching of URLs whose path contains =, which no rule
+    could match because it was percent-encoded in the URL but not
+    in the rule.
+  * Fixed matching of URLs whose path starts with //, which got
+    extra slashes before being matched.
+  * Fixed Allow: …/index.html, which, besides allowing the parent
+    directory as intended, also allowed URLs with a $ right after
+    that directory.
+  * Improved matching and parsing performance.
+  * Documentation improvements, including a rewritten parser
+    comparison table generated from benchmarks.
+
+-------------------------------------------------------------------

Old:
----
  protego-0.6.2.tar.gz

New:
----
  protego-0.7.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-Protego.spec ++++++
--- /var/tmp/diff_new_pack.KEb6bm/_old  2026-10-26 10:03:49.359326879 +0100
+++ /var/tmp/diff_new_pack.KEb6bm/_new  2026-10-26 10:03:49.361326963 +0100
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-Protego
-Version:        0.6.2
+Version:        0.7.0
 Release:        0
 Summary:        Pure-Python robotstxt parser with support for modern 
conventions
 License:        BSD-3-Clause

++++++ protego-0.6.2.tar.gz -> protego-0.7.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/.gitignore new/protego-0.7.0/.gitignore
--- old/protego-0.6.2/.gitignore        2020-02-02 01:00:00.000000000 +0100
+++ new/protego-0.7.0/.gitignore        2020-02-02 01:00:00.000000000 +0100
@@ -21,3 +21,6 @@
 .coverage
 .coverage.*
 coverage.*
+
+# test reports
+testenv.junit.xml
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/CHANGELOG.rst 
new/protego-0.7.0/CHANGELOG.rst
--- old/protego-0.6.2/CHANGELOG.rst     2020-02-02 01:00:00.000000000 +0100
+++ new/protego-0.7.0/CHANGELOG.rst     2020-02-02 01:00:00.000000000 +0100
@@ -2,6 +2,59 @@
 Protego changelog
 =================
 
+0.7.0 (2026-09-21)
+==================
+
+-   **Backward-incompatible:** ``Protego.parse()`` now raises a more suitable
+    ``TypeError`` instead of a ``ValueError`` when ``content`` is not a string.
+
+-   Added official support for Python 3.15.
+
+-   ``can_fetch()`` now allows ``/robots.txt`` whatever the rules say, as
+    required by RFC 9309.
+
+-   A group now applies to a user agent only if its product token appears in
+    that user agent at a token boundary, so that ``User-agent: bot`` no longer
+    applies to ``mybot``. A product token within a whole ``User-Agent`` header
+    value, e.g. ``Mozilla/5.0 (compatible; mybot/1.0)``, still matches.
+
+-   Percent-encoding is now normalized in the query string and the parameters
+    of a URL, and not only in its path, so that a rule matches whichever
+    spelling either of them uses, e.g. ``Disallow: /a?b=ツ`` now matches
+    ``/a?b=%E3%83%84``. The fragment is now left out of matching altogether.
+
+-   ``Visit-time`` now also accepts a hyphen between the two times, e.g.
+    ``0400-0845``, and times with a single-digit hour, e.g. ``400``.
+
+-   Malformed ``Crawl-delay`` values, such as a negative or infinite number,
+    and ``Request-rate`` values with zero requests or zero seconds, are now
+    ignored, as other malformed values already were.
+
+-   Improved the handling of invalid and misspelled lines.
+
+    ``Sitemap`` and ``Host`` lines are now read anywhere in the file,
+    including before any ``User-agent`` line, and no longer merge the groups
+    they sit between. A ``User-agent`` line without a value still ends the
+    preceding group. Lines written without a colon are now salvaged for every
+    directive, ``Visit-time`` included, and with any whitespace as the
+    separator. A line whose field is not a known directive is no longer read
+    as one.
+
+-   Fixed matching of URLs whose path contains ``=``, which no rule could
+    match because it was percent-encoded in the URL but not in the rule.
+
+-   Fixed matching of URLs whose path starts with ``//``, which got extra
+    slashes before being matched.
+
+-   Fixed ``Allow: …/index.html``, which, besides allowing the parent
+    directory as intended, also allowed URLs with a ``$`` right after that
+    directory.
+
+-   Improved matching and parsing performance.
+
+-   Documentation improvements, including a rewritten parser comparison table
+    generated from benchmarks.
+
 0.6.2 (2026-06-25)
 ==================
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/PKG-INFO new/protego-0.7.0/PKG-INFO
--- old/protego-0.6.2/PKG-INFO  2020-02-02 01:00:00.000000000 +0100
+++ new/protego-0.7.0/PKG-INFO  2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
-Metadata-Version: 2.4
+Metadata-Version: 2.5
 Name: Protego
-Version: 0.6.2
+Version: 0.7.0
 Summary: Pure-Python robots.txt parser with support for modern conventions
 Project-URL: Homepage, https://github.com/scrapy/protego
 Project-URL: Source, https://github.com/scrapy/protego
@@ -20,6 +20,7 @@
 Classifier: Programming Language :: Python :: 3.12
 Classifier: Programming Language :: Python :: 3.13
 Classifier: Programming Language :: Python :: 3.14
+Classifier: Programming Language :: Python :: 3.15
 Classifier: Programming Language :: Python :: Implementation :: CPython
 Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Topic :: Internet :: WWW/HTTP
@@ -39,8 +40,15 @@
    :target: 
https://github.com/scrapy/protego/actions/workflows/tests-ubuntu.yml
    :alt: CI
 
-Protego is a pure-Python ``robots.txt`` parser with support for modern
-conventions.
+Protego is a pure-Python ``robots.txt`` parser. It implements the parsing and
+URL matching rules of `RFC 9309`_, and additionally supports the
+``Crawl-delay``, ``Request-rate``, ``Visit-time`` and ``Host`` extensions.
+
+Fetching ``robots.txt`` is up to you, and so are the parts of `RFC 9309`_ that
+govern it, such as the handling of HTTP status codes and redirects, caching, 
and
+imposing a parsing limit.
+
+.. _RFC 9309: https://www.rfc-editor.org/rfc/rfc9309.html
 
 
 Install
@@ -115,27 +123,95 @@
 ==========
 
 The following table compares Protego to the most popular ``robots.txt`` parsers
-implemented in Python or featuring Python bindings:
-
-+----------------------------+---------+-----------------+--------+---------------------------+
-|                            | Protego | RobotFileParser | Reppy  | 
Robotexclusionrulesparser |
-+============================+=========+=================+========+===========================+
-| Implementation language    | Python  | Python          | C++    | Python     
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| Reference specification    | Google_ | `Martijn Koster’s 1996 draft`_        
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| `Wildcard support`_        | ✓       |                 | ✓      | ✓          
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| `Length-based precedence`_ | ✓       |                 | ✓      |            
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| Performance_               |         | +40%            | +1300% | -25%       
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-
-.. _Google: https://developers.google.com/search/reference/robots_txt
-.. _Length-based precedence: 
https://developers.google.com/search/reference/robots_txt#order-of-precedence-for-group-member-lines
-.. _Martijn Koster’s 1996 draft: https://www.robotstxt.org/norobots-rfc.txt
-.. _Performance: https://anubhavp28.github.io/gsoc-weekly-checkin-12/
-.. _Wildcard support: 
https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values
+implemented in Python. Performance is the speed difference against Protego, so
+a positive value means faster than Protego. It is measured over the
+``robots.txt`` of 100 of the most visited websites: the time taken to check the
+URLs their homepages link to, and the time taken to parse the files themselves.
+
+.. The table below is generated by benchmarks/compare.py. Regenerate it
+   rather than editing it by hand.
+
+   Its feature rows are probe results against the exact releases named in
+   "Version tested", not claims about the parsers in general. RobotFileParser
+   is the one that moves: CPython taught urllib.robotparser the RFC 9309
+   wildcards and longest-match precedence during the 3.14 series, after
+   3.14.3. Generated on 3.14.3 or older, its wildcard and length-based
+   precedence cells come out empty; generated on a later 3.14 they carry a
+   checkmark. Both are what the probes measured on that interpreter, so do not
+   flip those two cells by hand in either direction.
+
+.. comparison-table-start
+
+.. list-table::
+   :header-rows: 1
+   :stub-columns: 1
+
+   * -
+     - Protego
+     - RobotFileParser
+     - robotspy
+     - Robotexclusionrulesparser
+   * - Version tested
+     -
+     - Python 3.14.7
+     - 0.13.0
+     - 1.7.1
+   * - Reference specification
+     - `RFC 9309`_
+     - `Martijn Koster's 1996 draft`_
+     - `RFC 9309`_
+     - `Martijn Koster's 1996 draft`_
+   * - `Wildcard support`_
+     - ✓
+     - ✓
+     - ✓
+     - ✓
+   * - `Length-based precedence`_
+     - ✓
+     - ✓
+     - ✓
+     -
+   * - Crawl-delay
+     - ✓
+     - ✓
+     -
+     -
+   * - Request-rate
+     - ✓
+     -
+     -
+     -
+   * - Visit-time
+     - ✓
+     -
+     -
+     -
+   * - Sitemaps
+     - ✓
+     - ✓
+     - ✓
+     - ✓
+   * - Host
+     - ✓
+     -
+     -
+     -
+   * - Matching performance
+     -
+     - -62%
+     - -74%
+     - -96%
+   * - Parsing performance
+     -
+     - -71%
+     - +39%
+     - +56%
+
+.. comparison-table-end
+
+.. _Length-based precedence: 
https://www.rfc-editor.org/rfc/rfc9309.html#section-2.2.2
+.. _Martijn Koster's 1996 draft: https://www.robotstxt.org/norobots-rfc.txt
+.. _Wildcard support: https://www.rfc-editor.org/rfc/rfc9309.html#section-2.2.3
 
 
 API Reference
@@ -161,6 +237,11 @@
 *   ``can_fetch(url, user_agent)`` Return True if the user agent can fetch the
     URL, otherwise return ``False``.
 
+    *user_agent* may be a product token, such as ``"mybot"``, or a whole
+    ``User-Agent`` header value, such as ``"Mozilla/5.0 (compatible;
+    mybot/1.0)"``; a group applies when its product token appears in
+    *user_agent* at a token boundary.
+
 *   ``crawl_delay(user_agent)`` Return the crawl delay specified for the user
     agent as a float. If nothing is specified, return ``None``.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/README.rst new/protego-0.7.0/README.rst
--- old/protego-0.6.2/README.rst        2020-02-02 01:00:00.000000000 +0100
+++ new/protego-0.7.0/README.rst        2020-02-02 01:00:00.000000000 +0100
@@ -10,8 +10,15 @@
    :target: 
https://github.com/scrapy/protego/actions/workflows/tests-ubuntu.yml
    :alt: CI
 
-Protego is a pure-Python ``robots.txt`` parser with support for modern
-conventions.
+Protego is a pure-Python ``robots.txt`` parser. It implements the parsing and
+URL matching rules of `RFC 9309`_, and additionally supports the
+``Crawl-delay``, ``Request-rate``, ``Visit-time`` and ``Host`` extensions.
+
+Fetching ``robots.txt`` is up to you, and so are the parts of `RFC 9309`_ that
+govern it, such as the handling of HTTP status codes and redirects, caching, 
and
+imposing a parsing limit.
+
+.. _RFC 9309: https://www.rfc-editor.org/rfc/rfc9309.html
 
 
 Install
@@ -86,27 +93,95 @@
 ==========
 
 The following table compares Protego to the most popular ``robots.txt`` parsers
-implemented in Python or featuring Python bindings:
-
-+----------------------------+---------+-----------------+--------+---------------------------+
-|                            | Protego | RobotFileParser | Reppy  | 
Robotexclusionrulesparser |
-+============================+=========+=================+========+===========================+
-| Implementation language    | Python  | Python          | C++    | Python     
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| Reference specification    | Google_ | `Martijn Koster’s 1996 draft`_        
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| `Wildcard support`_        | ✓       |                 | ✓      | ✓          
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| `Length-based precedence`_ | ✓       |                 | ✓      |            
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-| Performance_               |         | +40%            | +1300% | -25%       
               |
-+----------------------------+---------+-----------------+--------+---------------------------+
-
-.. _Google: https://developers.google.com/search/reference/robots_txt
-.. _Length-based precedence: 
https://developers.google.com/search/reference/robots_txt#order-of-precedence-for-group-member-lines
-.. _Martijn Koster’s 1996 draft: https://www.robotstxt.org/norobots-rfc.txt
-.. _Performance: https://anubhavp28.github.io/gsoc-weekly-checkin-12/
-.. _Wildcard support: 
https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values
+implemented in Python. Performance is the speed difference against Protego, so
+a positive value means faster than Protego. It is measured over the
+``robots.txt`` of 100 of the most visited websites: the time taken to check the
+URLs their homepages link to, and the time taken to parse the files themselves.
+
+.. The table below is generated by benchmarks/compare.py. Regenerate it
+   rather than editing it by hand.
+
+   Its feature rows are probe results against the exact releases named in
+   "Version tested", not claims about the parsers in general. RobotFileParser
+   is the one that moves: CPython taught urllib.robotparser the RFC 9309
+   wildcards and longest-match precedence during the 3.14 series, after
+   3.14.3. Generated on 3.14.3 or older, its wildcard and length-based
+   precedence cells come out empty; generated on a later 3.14 they carry a
+   checkmark. Both are what the probes measured on that interpreter, so do not
+   flip those two cells by hand in either direction.
+
+.. comparison-table-start
+
+.. list-table::
+   :header-rows: 1
+   :stub-columns: 1
+
+   * -
+     - Protego
+     - RobotFileParser
+     - robotspy
+     - Robotexclusionrulesparser
+   * - Version tested
+     -
+     - Python 3.14.7
+     - 0.13.0
+     - 1.7.1
+   * - Reference specification
+     - `RFC 9309`_
+     - `Martijn Koster's 1996 draft`_
+     - `RFC 9309`_
+     - `Martijn Koster's 1996 draft`_
+   * - `Wildcard support`_
+     - ✓
+     - ✓
+     - ✓
+     - ✓
+   * - `Length-based precedence`_
+     - ✓
+     - ✓
+     - ✓
+     -
+   * - Crawl-delay
+     - ✓
+     - ✓
+     -
+     -
+   * - Request-rate
+     - ✓
+     -
+     -
+     -
+   * - Visit-time
+     - ✓
+     -
+     -
+     -
+   * - Sitemaps
+     - ✓
+     - ✓
+     - ✓
+     - ✓
+   * - Host
+     - ✓
+     -
+     -
+     -
+   * - Matching performance
+     -
+     - -62%
+     - -74%
+     - -96%
+   * - Parsing performance
+     -
+     - -71%
+     - +39%
+     - +56%
+
+.. comparison-table-end
+
+.. _Length-based precedence: 
https://www.rfc-editor.org/rfc/rfc9309.html#section-2.2.2
+.. _Martijn Koster's 1996 draft: https://www.robotstxt.org/norobots-rfc.txt
+.. _Wildcard support: https://www.rfc-editor.org/rfc/rfc9309.html#section-2.2.3
 
 
 API Reference
@@ -132,6 +207,11 @@
 *   ``can_fetch(url, user_agent)`` Return True if the user agent can fetch the
     URL, otherwise return ``False``.
 
+    *user_agent* may be a product token, such as ``"mybot"``, or a whole
+    ``User-Agent`` header value, such as ``"Mozilla/5.0 (compatible;
+    mybot/1.0)"``; a group applies when its product token appears in
+    *user_agent* at a token boundary.
+
 *   ``crawl_delay(user_agent)`` Return the crawl delay specified for the user
     agent as a float. If nothing is specified, return ``None``.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/pyproject.toml 
new/protego-0.7.0/pyproject.toml
--- old/protego-0.6.2/pyproject.toml    2020-02-02 01:00:00.000000000 +0100
+++ new/protego-0.7.0/pyproject.toml    2020-02-02 01:00:00.000000000 +0100
@@ -4,7 +4,7 @@
 
 [project]
 name = "Protego"
-version = "0.6.2"
+version = "0.7.0"
 description = "Pure-Python robots.txt parser with support for modern 
conventions"
 authors = [
     { name = "Anubhav Patel", email = "[email protected]" },
@@ -20,6 +20,7 @@
     "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: 3.13",
     "Programming Language :: Python :: 3.14",
+    "Programming Language :: Python :: 3.15",
     "Programming Language :: Python :: Implementation :: CPython",
     "Programming Language :: Python :: Implementation :: PyPy",
     "Topic :: Internet :: WWW/HTTP",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/src/protego/_protego.py 
new/protego-0.7.0/src/protego/_protego.py
--- old/protego-0.6.2/src/protego/_protego.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/protego-0.7.0/src/protego/_protego.py   2020-02-02 01:00:00.000000000 
+0100
@@ -2,6 +2,7 @@
 
 import logging
 from typing import TYPE_CHECKING
+from urllib.parse import urlparse
 
 from ._ruleset import RequestRate, VisitTime, _RuleSet
 
@@ -11,6 +12,8 @@
     # typing.Self requires Python 3.11
     from typing_extensions import Self
 
+    from ._urlpattern import _URLPattern
+
 
 logger = logging.getLogger(__name__)
 
@@ -31,19 +34,71 @@
 _VISIT_TIME_DIRECTIVE = {"visit-time", "visit time"}
 _HOST_DIRECTIVE = {"host"}
 
+# Directives that apply to the whole site rather than to a record group.
+_SITE_WIDE_DIRECTIVES = _SITEMAP_DIRECTIVE | _HOST_DIRECTIVE
+
+_ALL_DIRECTIVES = (
+    _DISALLOW_DIRECTIVE
+    | _ALLOW_DIRECTIVE
+    | _USER_AGENT_DIRECTIVE
+    | _SITEMAP_DIRECTIVE
+    | _CRAWL_DELAY_DIRECTIVE
+    | _REQUEST_RATE_DIRECTIVE
+    | _VISIT_TIME_DIRECTIVE
+    | _HOST_DIRECTIVE
+)
+
+_MAX_DIRECTIVE_WORDS = max(len(alias.split()) for alias in _ALL_DIRECTIVES)
+
 
 def _is_valid_directive_field(field: str) -> bool:
-    return any(
-        [
-            field in _DISALLOW_DIRECTIVE,
-            field in _ALLOW_DIRECTIVE,
-            field in _USER_AGENT_DIRECTIVE,
-            field in _SITEMAP_DIRECTIVE,
-            field in _CRAWL_DELAY_DIRECTIVE,
-            field in _REQUEST_RATE_DIRECTIVE,
-            field in _HOST_DIRECTIVE,
-        ]
-    )
+    return field in _ALL_DIRECTIVES
+
+
+def _extract_directive(line: str) -> tuple[str, str] | None:
+    """Split a non-empty line into a lowercased directive field and a
+    stripped value, or return None if the line cannot be read as a
+    directive. A line with "<field>:<value>" shape is returned even when
+    the field is not a known directive, so that the caller can count it as
+    an invalid directive.
+
+    We are more generous than the specs: we allow e.g. "User-agent: foo",
+    "User-agent foo", "User agent: foo" and "User agent foo" even though all
+    specs only specify the first form. We also tolerate some misspellings.
+    """
+    field: str | None = None
+    value = ""
+    if ":" in line:
+        field, raw_value = line.split(":", 1)
+        field = field.strip().lower()
+        value = raw_value.strip()
+        if _is_valid_directive_field(field):
+            # Happy path
+            return field, value
+        if not raw_value or raw_value[0].isspace():
+            # The field is not a known directive, but the colon reads as a
+            # field separator (it ends the line or is followed by
+            # whitespace), so we keep the "<field>:<value>" interpretation
+            # and treat the line as an unknown directive.
+            return field, value
+
+    # There is no colon separator, or the field found is not a known
+    # directive and its colon does not read as a field separator. We will
+    # be generous here and give the line a second chance, parsing it as a
+    # whitespace-separated "<field> <value>" directive. This also salvages
+    # lines whose value contains a colon of its own, e.g.
+    # "Sitemap https://example.com/sitemap.xml";.
+    words = line.split()
+    for word_count in range(1, _MAX_DIRECTIVE_WORDS + 1):
+        # Trying "<multiword> <field> <value>"
+        candidate = " ".join(words[:word_count]).lower()
+        if _is_valid_directive_field(candidate):
+            return candidate, " ".join(words[word_count:])
+
+    if field is None:
+        return None
+    # Salvage failed; keep the unknown "<field>:<value>" directive.
+    return field, value
 
 
 class Protego:
@@ -60,6 +115,9 @@
         # A memoization table mapping user agents (used in queries) to matched 
rule sets.
         self._matched_rule_set: dict[str, _RuleSet | None] = {}
 
+        # URL patterns by directive value, shared across rule sets.
+        self._url_patterns: dict[str, _URLPattern | None] = {}
+
         self._total_line_seen = 0
         self._invalid_directive_seen = 0
         self._total_directive_seen = 0
@@ -68,7 +126,7 @@
     def parse(cls, content: str) -> Self:
         o = cls()
         if not isinstance(content, str):
-            raise ValueError(f"Protego.parse expects str, got 
{type(content).__name__}")
+            raise TypeError(f"Protego.parse expects str, got 
{type(content).__name__}")
         o._parse_robotstxt(content)
         return o
 
@@ -95,37 +153,35 @@
             if not line:
                 continue
 
-            # Format for a valid robots.txt rule is "<field>:<value>"
-            if line.find(":") != -1:
-                field, value = line.split(":", 1)
-            else:
-                # We will be generous here and give it a second chance.
-                parts = line.split(" ")
-                if len(parts) < 2:
-                    continue
-
-                possible_filed = parts[0]
-                for i in range(1, len(parts)):
-                    if _is_valid_directive_field(possible_filed):
-                        field, value = possible_filed, " ".join(parts[i:])
-                        break
-                    possible_filed += " " + parts[i]
-                else:
-                    continue
+            parsed = _extract_directive(line)
+            if parsed is None:
+                continue
+            field, value = parsed
 
-            field = field.strip().lower()
-            value = value.strip()
+            # A user agent line, even one without a usable value, ends the
+            # previous record group.
+            if (
+                field in _USER_AGENT_DIRECTIVE
+                and previous_rule_field is not None
+                and previous_rule_field not in _USER_AGENT_DIRECTIVE
+            ):
+                current_rule_sets = []
+
+            # Sitemap and Host are site-wide directives and don't matter for
+            # record grouping.
+            if field not in _SITE_WIDE_DIRECTIVES:
+                previous_rule_field = field
 
             # Ignore rules with no value part (e.g. "Disallow: ", "Allow: ").
             if not value:
-                previous_rule_field = field
                 continue
 
-            # Ignore rules without a corresponding user agent.
+            # Ignore rules without a corresponding user agent, except
+            # site-wide directives which need none.
             if (
                 not current_rule_sets
                 and field not in _USER_AGENT_DIRECTIVE
-                and field not in _SITEMAP_DIRECTIVE
+                and field not in _SITE_WIDE_DIRECTIVES
             ):
                 logger.debug(
                     f"Rule at line {self._total_line_seen} without any user 
agent to enforce it on."
@@ -135,12 +191,6 @@
             self._total_directive_seen += 1
 
             if field in _USER_AGENT_DIRECTIVE:
-                if (
-                    previous_rule_field
-                    and previous_rule_field not in _USER_AGENT_DIRECTIVE
-                ):
-                    current_rule_sets = []
-
                 # Wildcards are not supported in the user agent values.
                 # We will be generous here and remove all the wildcards.
                 user_agent = value.strip().lower()
@@ -192,8 +242,6 @@
             else:
                 self._invalid_directive_seen += 1
 
-            previous_rule_field = field
-
         for rule_set in self._user_agents.values():
             rule_set.finalize_rules()
 
@@ -217,6 +265,8 @@
 
     def can_fetch(self, url: str, user_agent: str) -> bool:
         """Return True if the user agent can fetch the URL, otherwise return 
False."""
+        if "/robots.txt" in url and urlparse(url).path == "/robots.txt":
+            return True
         matched_rule_set = self._get_matching_rule_set(user_agent)
         if not matched_rule_set:
             return True
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/src/protego/_ruleset.py 
new/protego-0.7.0/src/protego/_ruleset.py
--- old/protego-0.6.2/src/protego/_ruleset.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/protego-0.7.0/src/protego/_ruleset.py   2020-02-02 01:00:00.000000000 
+0100
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import logging
+import math
 from typing import TYPE_CHECKING, NamedTuple
 
 from ._urlpattern import _URLPattern
@@ -38,6 +39,8 @@
     def __init__(self, parser_instance: Protego):
         self.user_agent: str | None = None
         self._rules: list[_Rule] = []
+        self._plain_prefixes: tuple[str, ...] | None = None
+        self._special_rules: list[_Rule] = []
         self._crawl_delay: float | None = None
         self._req_rate: RequestRate | None = None
         self._visit_time: VisitTime | None = None
@@ -49,42 +52,95 @@
         robotname = robotname.strip().lower()
         if self.user_agent == "*":
             return 1
-        if self.user_agent in robotname:
-            return len(self.user_agent)
+        # Match the product token only at a token boundary. This avoids
+        # false positives such as "bot" matching "mybot", while still
+        # matching a token within a full User-Agent header, e.g.
+        # "Mozilla/5.0 (compatible; Foobot/1.0)" matching "foobot".
+        index = robotname.find(self.user_agent)
+        while index != -1:
+            if index == 0 or not (
+                robotname[index - 1].isalnum() or robotname[index - 1] in "-_"
+            ):
+                return len(self.user_agent)
+            index = robotname.find(self.user_agent, index + 1)
         return 0
 
+    def _url_pattern(self, pattern: str) -> _URLPattern | None:
+        """Return the URL pattern for a directive value, or None if the value
+        quotes to nothing.
+
+        The same value usually appears once per user agent in a robots.txt,
+        so patterns are shared across the rule sets of a parser.
+        """
+        cache = self._parser_instance._url_patterns
+        if pattern not in cache:
+            quoted = _quote_pattern(pattern)
+            cache[pattern] = _URLPattern(quoted) if quoted else None
+        return cache[pattern]
+
     def allow(self, pattern: str) -> None:
         if "$" in pattern:
             self.allow(pattern.replace("$", _hexescape("$")))
 
-        pattern = _quote_pattern(pattern)
-        if not pattern:
+        url_pattern = self._url_pattern(pattern)
+        if url_pattern is None:
             return
-        self._rules.append(_Rule(field="allow", value=_URLPattern(pattern)))
+        self._rules.append(_Rule(field="allow", value=url_pattern))
 
         # If index.html is allowed, we interpret this as / being allowed too.
-        if pattern.endswith("/index.html"):
-            self.allow(pattern[:-10] + "$")
+        page = "index.html"
+        quoted = url_pattern._pattern
+        if quoted.endswith(f"/{page}"):
+            # Add the rule directly; going through allow() would treat the
+            # "$" anchor as a literal dollar sign too.
+            self._rules.append(
+                _Rule(field="allow", 
value=_URLPattern(quoted.removesuffix(page) + "$"))
+            )
 
     def disallow(self, pattern: str) -> None:
         if "$" in pattern:
             self.disallow(pattern.replace("$", _hexescape("$")))
 
-        pattern = _quote_pattern(pattern)
-        if not pattern:
+        url_pattern = self._url_pattern(pattern)
+        if url_pattern is None:
             return
-        self._rules.append(_Rule(field="disallow", value=_URLPattern(pattern)))
+        self._rules.append(_Rule(field="disallow", value=url_pattern))
 
     def finalize_rules(self) -> None:
         self._rules.sort(
             key=lambda r: (r.value.priority, r.field == "allow"), reverse=True
         )
 
+    def _build_index(self) -> tuple[str, ...]:
+        """Split the rules into the patterns that can only match as a prefix
+        of a URL and the rules that can match otherwise.
+
+        Built on the first match rather than at parse time because a
+        robots.txt declares many rule sets and a crawler queries one.
+        """
+        prefixes = []
+        special = []
+        for rule in self._rules:
+            pattern = rule.value
+            if pattern._contains_asterisk or pattern._contains_dollar:
+                special.append(rule)
+            else:
+                prefixes.append(pattern._pattern)
+        self._special_rules = special
+        self._plain_prefixes = tuple(prefixes)
+        return self._plain_prefixes
+
     def can_fetch(self, url: str) -> bool:
         """Return if the url can be fetched."""
         url = _quote_path(url)
+        # A plain pattern matches only as a prefix of the URL, so a single
+        # startswith over all of them rules every one of them out at once.
+        prefixes = self._plain_prefixes
+        if prefixes is None:
+            prefixes = self._build_index()
+        rules = self._rules if url.startswith(prefixes) else 
self._special_rules
         allowed = True
-        for rule in self._rules:
+        for rule in rules:
             if rule.value.match(url):
                 if rule.field == "disallow":
                     allowed = False
@@ -99,13 +155,17 @@
     @crawl_delay.setter
     def crawl_delay(self, delay: str) -> None:
         try:
-            self._crawl_delay = float(delay)
+            parsed_delay = float(delay)
         except ValueError:
+            parsed_delay = None
+        if parsed_delay is None or not math.isfinite(parsed_delay) or 
parsed_delay < 0:
             # Value is malformed, do nothing.
             logger.debug(
                 f"Malformed rule at line 
{self._parser_instance._total_line_seen} : "
                 f"cannot set crawl delay to '{delay}'. Ignoring this rule."
             )
+            return
+        self._crawl_delay = parsed_delay
 
     @property
     def request_rate(self) -> RequestRate | None:
@@ -130,6 +190,9 @@
                 seconds = int(seconds_str)
             requests = int(requests_str)
 
+            if requests <= 0 or seconds <= 0:
+                raise ValueError(f"Request rate must be positive: {value!r}")
+
             if time_unit == "m":
                 seconds *= 60
             elif time_unit == "h":
@@ -141,7 +204,7 @@
             end_time = None
             if time_period:
                 start_time, end_time = _parse_time_period(time_period)
-        except Exception:
+        except Exception:  # noqa: BLE001
             # Value is malformed, do nothing.
             logger.debug(
                 f"Malformed rule at line 
{self._parser_instance._total_line_seen} : "
@@ -159,8 +222,13 @@
     @visit_time.setter
     def visit_time(self, value: str) -> None:
         try:
-            start_time, end_time = _parse_time_period(value, separator=" ")
-        except Exception:
+            try:
+                # "0400-0845"
+                start_time, end_time = _parse_time_period(value)
+            except ValueError:
+                # "0400 0845"
+                start_time, end_time = _parse_time_period(value, separator=" ")
+        except Exception:  # noqa: BLE001
             logger.debug(
                 f"Malformed rule at line 
{self._parser_instance._total_line_seen} : "
                 f"cannot set visit time using '{value}'. Ignoring this rule."
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/src/protego/_utils.py 
new/protego-0.7.0/src/protego/_utils.py
--- old/protego-0.6.2/src/protego/_utils.py     2020-02-02 01:00:00.000000000 
+0100
+++ new/protego-0.7.0/src/protego/_utils.py     2020-02-02 01:00:00.000000000 
+0100
@@ -1,17 +1,47 @@
 from __future__ import annotations
 
+import re
 from datetime import time
-from urllib.parse import ParseResult, quote, urlparse, urlunparse
+from urllib.parse import quote, urlsplit
 
 _HEX_DIGITS = set("0123456789ABCDEFabcdef")
 
+# Escapes left as they are while normalizing a URL for comparison: the path
+# separator, and "%" so that escapes already present are not doubled.
+_KEPT = "/%"
+
+# Patterns additionally keep "*", which stands for any sequence of characters.
+_PATTERN_KEPT = f"{_KEPT}*"
+
+# Characters left verbatim rather than encoded. "=" is absent from the sets
+# above, so that "%3D" is decoded into it and both spellings compare equal.
+_SAFE = f"{_KEPT}="
+_PATTERN_SAFE = f"{_PATTERN_KEPT}="
+
+# A URL whose path is made only of characters that percent-encoding leaves
+# alone, and that urlsplit does not read as a delimiter, needs no quoting. A
+# path starting with "//" is left out because urlsplit reads it as an
+# authority.
+_QUOTED_URL = re.compile(
+    r"(?:[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*)?(/(?!/)[A-Za-z0-9_.~/=-]*)"
+).fullmatch
+
+# Patterns keep "*" unquoted as well, and their trailing "$" as it is.
+_QUOTED_PATTERN = re.compile(r"[A-Za-z0-9_.~/=*-]+\$?").fullmatch
+
+
+def _parse_time_of_day(value: str) -> time:
+    """Parse an HMM or HHMM time-of-day value."""
+    value = value.strip()
+    if not (3 <= len(value) <= 4 and value.isascii() and value.isdigit()):
+        raise ValueError(f"Invalid time of day: {value!r}")
+    return time(int(value[:-2]), int(value[-2:]))
+
 
 def _parse_time_period(time_period: str, separator: str = "-") -> tuple[time, 
time]:
     """Parse a string with a time period into a tuple of start and end 
times."""
     start_time_str, end_time_str = time_period.split(separator)
-    start_time = time(int(start_time_str[:2]), int(start_time_str[-2:]))
-    end_time = time(int(end_time_str[:2]), int(end_time_str[-2:]))
-    return start_time, end_time
+    return _parse_time_of_day(start_time_str), _parse_time_of_day(end_time_str)
 
 
 def _unquote(url: str, ignore: str = "", errors: str = "replace") -> str:
@@ -48,35 +78,53 @@
     return f"%{ord(char):02X}"
 
 
-def _quote_path(path: str) -> str:
-    """Return percent encoded path."""
-    parts = urlparse(path)
-    path = _unquote(parts.path, ignore="/%")
-    path = quote(path, safe="/%")
-
-    parts = ParseResult("", "", path, parts.params, parts.query, 
parts.fragment)
-    path = urlunparse(parts)
-    return path or "/"
+def _quote(value: str, kept: str, safe: str) -> str:
+    """Return *value* with its percent-encoding normalized.
+
+    Escapes other than those of the characters in *kept* are decoded,
+    characters that need an escape are encoded, and those in *safe* are left
+    verbatim. Both sides of a comparison must be normalized with the same
+    *kept* and *safe* sets, or equivalent strings end up spelled differently.
+    """
+    return quote(_unquote(value, ignore=kept), safe=safe)
+
+
+def _quote_path(url: str) -> str:
+    """Return the path and query of *url*, normalized for comparison.
+
+    The scheme, the authority and the fragment are dropped, since rules never
+    match against them.
+    """
+    quoted = _QUOTED_URL(url)
+    if quoted:
+        return quoted[1]
+
+    url = url.partition("#")[0]
+    parts = urlsplit(url)
+    path = parts.path
+    # A "?" with nothing after it is still part of what rules match against.
+    if "?" in url:
+        path += f"?{parts.query}"
+    path = _quote(path, _KEPT, _SAFE)
+    return path if path.startswith("/") else f"/{path}"
 
 
 def _quote_pattern(pattern: str) -> str:
+    """Return *pattern* normalized for comparison.
+
+    A trailing "$", which anchors the pattern to the end of the URL, is kept
+    as it is; a "$" anywhere else is an ordinary character.
+    """
+    if _QUOTED_PATTERN(pattern):
+        return pattern
+
+    # A rule written as an absolute URL matches a path with that URL in it,
+    # which is what the site owner who wrote it by mistake gets.
     if pattern.startswith(("https://";, "http://";)):
-        pattern = "/" + pattern
-    if pattern.startswith("//"):
-        pattern = "//" + pattern
-
-    # Corner case for query only (e.g. '/abc?') and param only (e.g. '/abc;') 
URLs.
-    # Save the last character otherwise, urlparse will kill it.
-    last_char = ""
-    if pattern[-1] == "?" or pattern[-1] == ";" or pattern[-1] == "$":
-        last_char = pattern[-1]
-        pattern = pattern[:-1]
+        pattern = f"/{pattern}"
 
-    parts = urlparse(pattern)
-    pattern = _unquote(parts.path, ignore="/*$%")
-    pattern = quote(pattern, safe="/*%=")
-
-    parts = ParseResult(
-        "", "", pattern + last_char, parts.params, parts.query, parts.fragment
-    )
-    return urlunparse(parts)
+    anchor = ""
+    if pattern.endswith("$"):
+        anchor = "$"
+        pattern = pattern[:-1]
+    return _quote(pattern, _PATTERN_KEPT, _PATTERN_SAFE) + anchor
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/tests/test_on_google_spec.py 
new/protego-0.7.0/tests/test_on_google_spec.py
--- old/protego-0.6.2/tests/test_on_google_spec.py      2020-02-02 
01:00:00.000000000 +0100
+++ new/protego-0.7.0/tests/test_on_google_spec.py      2020-02-02 
01:00:00.000000000 +0100
@@ -39,6 +39,32 @@
 
 
 @pytest.mark.parametrize(
+    ("robots_user_agent", "crawler_user_agent", "applies"),
+    [
+        # A product token must not match as a substring in the middle of
+        # another token (https://github.com/scrapy/protego/issues/65).
+        ("bot", "mybot", False),
+        ("google", "notgooglebot", False),
+        # Exact matches and boundary prefixes do apply.
+        ("googlebot", "googlebot", True),
+        ("googlebot", "googlebot-news", True),
+        ("google", "googlebot-mobile", True),
+        # A token embedded in a full User-Agent header still applies.
+        ("foobot", "Mozilla/5.0 (compatible; Foobot/1.0)", True),
+        # Matching is case-insensitive.
+        ("foobot", "FOOBOT", True),
+    ],
+)
+def test_user_agent_boundary_matching(robots_user_agent, crawler_user_agent, 
applies):
+    content = f"User-Agent: {robots_user_agent}\nDisallow: /private\n"
+    rp = Protego.parse(content=content)
+    # When the group applies, /private is disallowed for the crawler.
+    assert (
+        not rp.can_fetch("http://example.com/private";, crawler_user_agent)
+    ) == applies
+
+
[email protected](
     ("pattern", "path", "match"),
     [
         ("/", "/harry", True),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/tests/test_protego.py 
new/protego-0.7.0/tests/test_protego.py
--- old/protego-0.6.2/tests/test_protego.py     2020-02-02 01:00:00.000000000 
+0100
+++ new/protego-0.7.0/tests/test_protego.py     2020-02-02 01:00:00.000000000 
+0100
@@ -3,7 +3,7 @@
 import pytest
 
 from protego import Protego
-from protego._utils import _parse_time_period
+from protego._utils import _parse_time_period, _quote_path, _quote_pattern
 
 
 class TestProtego:
@@ -179,28 +179,32 @@
         rp = Protego.parse(content=content)
         assert rp.preferred_host is None
 
-    def test_crawl_delay(self):
+    @pytest.mark.parametrize("value", [0, 10, 15])
+    def test_crawl_delay(self, value: int) -> None:
         content = (
             "User-agent: * \n"
             "Disallow: /disallowed \n"
             "Allow: /allowed \n"
             "Crawl-delay: 10 \n"
             "User-agent: testbot\n"
-            "Crawl-delay: 15 \n"
+            f"Crawl-delay: {value}\n"
         )
         rp = Protego.parse(content=content)
         assert rp.crawl_delay("*") == 10.0
-        assert rp.crawl_delay("testbot") == 15.0
+        assert rp.crawl_delay("testbot") == value
 
-    def test_malformed_crawl_delay(self):
+    @pytest.mark.parametrize(
+        "value", ["random_word", "-5", "-0.5", "inf", "-inf", "nan"]
+    )
+    def test_malformed_crawl_delay(self, value: str) -> None:
         content = (
             "User-agent: * \n"
             "Disallow: /disallowed \n"
             "Allow: /allowed \n"
-            "Crawl-delay: random_word"
+            f"Crawl-delay: {value}"
         )
         rp = Protego.parse(content=content)
-        assert rp.crawl_delay("*") is None
+        assert rp.crawl_delay("*") is None, value
 
     def test_no_crawl_delay(self):
         content = "User-agent: * \nDisallow: /disallowed \nAllow: /allowed"
@@ -317,6 +321,25 @@
         rp = Protego.parse(content=content)
         assert rp.request_rate("two") is None
 
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "one/two",
+            "5",
+            "-1/5",
+            "0/5",
+            "1/0",
+            "0/0",
+            "1/-5",
+            # A malformed time window invalidates the whole rule.
+            "1/5 9-17",
+        ],
+    )
+    def test_malformed_request_rate(self, value):
+        content = f"User-agent: *\nRequest-rate: {value}"
+        rp = Protego.parse(content=content)
+        assert rp.request_rate("*") is None
+
     def test_empty_response(self):
         """empty response should equal 'allow all'"""
         rp = Protego.parse(content="")
@@ -325,6 +348,21 @@
         assert rp.can_fetch("https://site.local/index.html";, "*")
         assert rp.can_fetch("https://site.local/disallowed";, "*")
 
+    def test_wildcard_rule_in_a_rule_set_with_plain_rules(self):
+        content = """User-agent: *
+                     Disallow: /admin/
+                     Disallow: /private/
+                     Disallow: /*.pdf
+                     Disallow: /tmp$
+                """
+        rp = Protego.parse(content=content)
+
+        assert not rp.can_fetch("https://site.local/docs/manual.pdf";, "bot")
+        assert not rp.can_fetch("https://site.local/tmp";, "bot")
+        assert not rp.can_fetch("https://site.local/admin/index.html";, "bot")
+        assert rp.can_fetch("https://site.local/docs/manual.txt";, "bot")
+        assert rp.can_fetch("https://site.local/tmp/file";, "bot")
+
     def test_allowed_wildcards(self):
         content = """User-agent: first
                      Disallow: /disallowed/*/end$
@@ -655,6 +693,21 @@
         assert rp.can_fetch("https://site.local/path1";, "two")
         assert rp.can_fetch("https://site.local/path2";, "two")
 
+    def test_directives_without_path(self):
+        """Values whose path is empty after quoting, such as a bare URL
+        without a path, add no rule."""
+        content = """
+        User-Agent: one
+        Disallow: ftp://site.local
+
+        User-Agent: two
+        Allow: ftp://site.local
+        Disallow: /
+        """
+        rp = Protego.parse(content=content)
+        assert rp.can_fetch("https://site.local/path";, "one")
+        assert not rp.can_fetch("https://site.local/path";, "two")
+
     def test_empty_record_group(self):
         content = """
         User-Agent: harrybot
@@ -665,6 +718,108 @@
         assert rp.can_fetch("https://site.local/path1";, "testbot")
         assert rp.can_fetch("https://site.local/path2";, "testbot")
 
+    def test_empty_user_agent_line(self):
+        """A user agent line with no value still starts a new record group."""
+        content = """
+        User-Agent: alpha
+        Disallow: /x
+        User-Agent:
+        User-Agent: beta
+        Disallow: /y
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/x";, "alpha")
+        assert rp.can_fetch("https://site.local/y";, "alpha")
+        assert rp.can_fetch("https://site.local/x";, "beta")
+        assert not rp.can_fetch("https://site.local/y";, "beta")
+
+        # Rules following a lone empty user agent line apply to no one.
+        content = """
+        User-Agent: alpha
+        Disallow: /x
+        User-Agent:
+        Disallow: /y
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/x";, "alpha")
+        assert rp.can_fetch("https://site.local/y";, "alpha")
+
+        # An empty user agent line between two user agent lines does not
+        # split their group.
+        content = """
+        User-Agent: alpha
+        User-Agent:
+        User-Agent: beta
+        Disallow: /x
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/x";, "alpha")
+        assert not rp.can_fetch("https://site.local/x";, "beta")
+
+        # A colon-less, value-less user agent line behaves the same as
+        # "User-Agent:".
+        content = """
+        User-Agent: alpha
+        Disallow: /x
+        user agent
+        Disallow: /y
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/x";, "alpha")
+        assert rp.can_fetch("https://site.local/y";, "alpha")
+
+    def test_global_directives_do_not_split_groups(self):
+        """Sitemap and Host lines are site-wide; consecutive user agent lines
+        form a single group across them."""
+        content = """
+        User-Agent: one
+        Sitemap: https://site.local/sitemap.xml
+        User-Agent: two
+        Host: www.site.local
+        User-Agent: three
+        Disallow: /disallowed
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/disallowed";, "one")
+        assert not rp.can_fetch("https://site.local/disallowed";, "two")
+        assert not rp.can_fetch("https://site.local/disallowed";, "three")
+        assert list(rp.sitemaps) == ["https://site.local/sitemap.xml";]
+        assert rp.preferred_host == "www.site.local"
+
+        # They do not prevent a legitimate group split either.
+        content = """
+        User-Agent: one
+        Disallow: /disallowed
+        Sitemap: https://site.local/sitemap.xml
+        User-Agent: two
+        Crawl-delay: 5
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/disallowed";, "one")
+        assert rp.can_fetch("https://site.local/disallowed";, "two")
+        assert rp.crawl_delay("one") is None
+        assert rp.crawl_delay("two") == 5.0
+
+    def test_preferred_host_before_user_agent(self):
+        """Host, like Sitemap, is a site-wide directive and needs no
+        preceding user agent line."""
+        content = """
+        Host: www.site.local
+        Sitemap: https://site.local/sitemap.xml
+        """
+        rp = Protego.parse(content=content)
+        assert rp.preferred_host == "www.site.local"
+        assert list(rp.sitemaps) == ["https://site.local/sitemap.xml";]
+
+        content = """
+        Host: www.site.local
+        User-Agent: *
+        Disallow: /disallowed
+        """
+        rp = Protego.parse(content=content)
+        assert rp.preferred_host == "www.site.local"
+        assert not rp.can_fetch("https://site.local/disallowed";, "FooBot")
+
     def test_1994rfc_example(self):
         """Test parser on examples form 1994 RFC."""
         content = """
@@ -823,6 +978,16 @@
         assert not rp.can_fetch("/", "SomeOtherBot")
         assert not rp.can_fetch("/blahblahblah", "SomeOtherBot")
 
+    def test_robotstxt_is_always_allowed(self):
+        content = """
+        User-agent: *
+        Disallow: /
+        """
+        rp = Protego.parse(content=content)
+        assert rp.can_fetch("/robots.txt", "foobot")
+        assert rp.can_fetch("http://www.example.com/robots.txt";, "foobot")
+        assert not rp.can_fetch("http://www.example.com/a/robots.txt";, 
"foobot")
+
     def test_grouping_unknown_keys(self):
         """
         When we encounter unknown keys, we should disregard any grouping that 
may have
@@ -886,6 +1051,103 @@
         assert not rp.can_fetch("http://foo.bar/myprofile";, "foo*bot")
         assert not rp.can_fetch("http://foo.bar/myprofile";, "foobot")
 
+    def test_generosity_case_insensitivity(self):
+        """Colon-less directive lines are salvaged regardless of case."""
+        content = """
+        User-Agent FooBot
+        Disallow /disallowed
+        Allow /disallowed/exception
+        Crawl-delay 5
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/disallowed";, "FooBot")
+        assert rp.can_fetch("https://site.local/disallowed/exception";, 
"FooBot")
+        assert rp.can_fetch("https://site.local/disallowed";, "BarBot")
+        assert rp.crawl_delay("FooBot") == 5.0
+
+    def test_generosity_visit_time(self):
+        """Colon-less Visit-time lines are salvaged like other directives."""
+        content = """
+        User-agent: *
+        visit-time 0200 0630
+        """
+        rp = Protego.parse(content=content)
+        visit_time = rp.visit_time("FooBot")
+        assert visit_time is not None
+        assert visit_time.start_time == time(2, 0)
+        assert visit_time.end_time == time(6, 30)
+
+    def test_generosity_sitemap(self):
+        """A colon-less Sitemap line still contains a colon in its URL value,
+        so it is parsed as "<field>:<value>" with a nonsense field before the
+        space-separated fallback can salvage it."""
+        content = """
+        User-agent: *
+        Disallow: /disallowed
+        Sitemap https://site.local/sitemap.xml
+        """
+        rp = Protego.parse(content=content)
+        assert list(rp.sitemaps) == ["https://site.local/sitemap.xml";]
+        assert not rp.can_fetch("https://site.local/disallowed";, "FooBot")
+
+    def test_generosity_not_for_unknown_colon_directives(self):
+        """A line with an unknown "<field>: <value>" directive is ignored,
+        even if it starts with a directive name; only a colon that cannot be
+        a field separator (e.g. inside a URL) triggers the space-separated
+        fallback."""
+        content = """
+        User-agent: *
+        Disallow: /disallowed
+        User-agent must match exactly: see the docs
+        Allow: /disallowed/exception
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/disallowed";, "FooBot")
+        assert rp.can_fetch("https://site.local/disallowed/exception";, 
"FooBot")
+
+        content = """
+        Host: www.site.local
+        Host name: www.other.example
+        Sitemap index: see https://site.local/sitemap.xml
+        """
+        rp = Protego.parse(content=content)
+        assert rp.preferred_host == "www.site.local"
+        assert list(rp.sitemaps) == []
+
+    def test_generosity_tab_separated(self):
+        """Colon-less directive lines may use any whitespace as the
+        field-value separator."""
+        content = "User-agent: *\nDisallow\t/disallowed"
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/disallowed";, "FooBot")
+
+    def test_empty_field_line_does_not_merge_groups(self):
+        """A line with an empty field (e.g. a stray ":") is ignored and does
+        not prevent the next user agent line from starting a new group."""
+        content = """
+        User-Agent: alpha
+        Disallow: /x
+        :
+        User-Agent: beta
+        Disallow: /y
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/x";, "alpha")
+        assert rp.can_fetch("https://site.local/y";, "alpha")
+        assert not rp.can_fetch("https://site.local/y";, "beta")
+
+        content = """
+        User-Agent: alpha
+        Disallow: /x
+        : foo
+        User-Agent: beta
+        Disallow: /y
+        """
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("https://site.local/x";, "alpha")
+        assert rp.can_fetch("https://site.local/y";, "alpha")
+        assert not rp.can_fetch("https://site.local/y";, "beta")
+
     def test_directive_case_insensitivity(self):
         content = """
         USER-AGENT: onebot
@@ -957,6 +1219,9 @@
         assert rp.can_fetch("http://foo.com/allowed-slash/index.html";, 
"footbot")
         assert not rp.can_fetch("http://foo.com/allowed-slash/index.htm";, 
"footbot")
         assert not rp.can_fetch("http://foo.com/anyother-url";, "footbot")
+        # The "$" anchor of the added directory rule must not leak a rule
+        # matching a literal dollar sign.
+        assert not rp.can_fetch("http://foo.com/allowed-slash/$budget";, 
"footbot")
 
     def test_percentage_encoding(self):
         content = (
@@ -984,6 +1249,29 @@
         assert rp.can_fetch("http://foo.bar/foo/bar/baz";, "FooBot")
         assert rp.can_fetch("http://foo.bar/foo/bar/%62%61%7A";, "FooBot")
 
+    def test_percentage_encoding_query(self):
+        """Queries are encoded like paths, so that a rule matches a URL
+        regardless of how either of the two spells a character."""
+        content = "User-agent: FooBot\nDisallow: /en-uk/*q=*relevance*\n"
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch(
+            "http://foo.bar/en-uk/c/4018?q=%3Arelevance%3Atype%3AFood";, 
"FooBot"
+        )
+        assert not rp.can_fetch("http://foo.bar/en-uk/c/4018?q=:relevance";, 
"FooBot")
+        assert rp.can_fetch("http://foo.bar/en-uk/c/4018?q=:price";, "FooBot")
+
+        content = "User-agent: FooBot\nDisallow: /\nAllow: /foo?bar=ツ\n"
+        rp = Protego.parse(content=content)
+        assert rp.can_fetch("http://foo.bar/foo?bar=ツ";, "FooBot")
+        assert rp.can_fetch("http://foo.bar/foo?bar=%E3%83%84";, "FooBot")
+        assert rp.can_fetch("http://foo.bar/foo?bar=%e3%83%84";, "FooBot")
+
+        # A parameter and a query are matched against, a fragment is not.
+        content = "User-agent: FooBot\nDisallow: /foo;a=b?c=d$\n"
+        rp = Protego.parse(content=content)
+        assert not rp.can_fetch("http://foo.bar/foo;a=b?c=d";, "FooBot")
+        assert not rp.can_fetch("http://foo.bar/foo;a=b?c=d#e";, "FooBot")
+
     def test_url_case_sensitivity(self):
         content = "user-agent: FooBot\ndisallow: /x/\n"
         rp = Protego.parse(content=content)
@@ -1058,7 +1346,7 @@
 
     def test_bytestrings(self):
         content = b"User-Agent: FootBot\nDisallow: /something"
-        with pytest.raises(ValueError, match=r"Protego\.parse expects str, got 
bytes"):
+        with pytest.raises(TypeError, match=r"Protego\.parse expects str, got 
bytes"):
             Protego.parse(content=content)  # type: ignore[arg-type]
 
     def test_leading_double_slash_in_pattern(self):
@@ -1072,14 +1360,39 @@
         assert not rp.can_fetch("http://example.com//folder/page";, "FooBot")
 
     def test_visit_time(self):
-        """Some website specified allow time for crawling in UTC"""
-        content = "User-Agent: *\nVisit-time: 0200 0630\nUser-Agent: NoTime"
+        """The de-facto Visit-time convention, from the "Extended Standard
+        for Robot Exclusion", is hyphen-separated."""
+        content = "User-Agent: *\nVisit-time: 0400-0845\nUser-Agent: NoTime"
         rp = Protego.parse(content)
-        visit_time = rp.visit_time("FooBoot")
+        visit_time = rp.visit_time("FooBot")
+        assert visit_time is not None
+        assert visit_time.start_time == time(4, 0)
+        assert visit_time.end_time == time(8, 45)
+        assert rp.visit_time("NoTime") is None
+
+        # The space-separated form is also accepted.
+        content = "User-Agent: *\nVisit-time: 0200 0630"
+        rp = Protego.parse(content)
+        visit_time = rp.visit_time("FooBot")
         assert visit_time is not None
         assert visit_time.start_time == time(2, 0)
         assert visit_time.end_time == time(6, 30)
-        assert rp.visit_time("NoTime") is None
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "9-17",
+            "900",
+            "0400-",
+            "0400-0845-1000",
+            "2500-2600",
+            "abcd-efgh",
+        ],
+    )
+    def test_malformed_visit_time(self, value):
+        content = f"User-Agent: *\nVisit-time: {value}"
+        rp = Protego.parse(content)
+        assert rp.visit_time("FooBot") is None
 
     def test_parse_time_period(self):
         start_time, end_time = _parse_time_period("0100-1000")
@@ -1090,6 +1403,15 @@
         assert start_time == time(5, 0)
         assert end_time == time(6, 0)
 
+        # Three-digit values are read as HMM.
+        start_time, end_time = _parse_time_period("900-1700")
+        assert start_time == time(9, 0)
+        assert end_time == time(17, 0)
+
+        for time_period in ["9-17", "18-19", "-", "0900-", "090000-100000"]:
+            with pytest.raises(ValueError, match="Invalid time of day"):
+                _parse_time_period(time_period)
+
     def test_disallow_query_wildcard(self):
         content = "User-agent: * \nDisallow: /*s="
         rp = Protego.parse(content=content)
@@ -1099,6 +1421,20 @@
 
 
 @pytest.mark.parametrize(
+    ("url", "allowed"),
+    [
+        ("https://example.com/1/filter/page=5/";, True),
+        ("https://example.com/1/filter/page%3D5/";, True),
+        ("https://example.com/1/filter/page=5/x";, False),
+    ],
+)
+def test_equal_sign_in_path(url, allowed):
+    content = "User-agent: *\nAllow: /*/filter/page=*/$\nDisallow: /\n"
+    rp = Protego.parse(content)
+    assert rp.can_fetch(url, "*") == allowed
+
+
[email protected](
     ("allow", "disallow", "url", "allowed"),
     [
         ("*/p", "/", "http://example.com/page";, True),
@@ -1121,3 +1457,29 @@
     content = f"User-agent: *\nDisallow: {disallow}\n"
     rp = Protego.parse(content)
     assert rp.can_fetch(url, "*")
+
+
[email protected](
+    ("value", "path", "pattern"),
+    [
+        ("/a/b", "/a/b", "/a/b"),
+        ("/a=b", "/a=b", "/a=b"),
+        ("//host/path", "/path", "//host/path"),
+        ("/a%2fb", "/a%2Fb", "/a%2Fb"),
+        ("/a?b", "/a%3Fb", "/a%3Fb"),
+        ("/a;b", "/a%3Bb", "/a%3Bb"),
+        ("/a:b", "/a%3Ab", "/a%3Ab"),
+        ("/a b", "/a%20b", "/a%20b"),
+        ("/á", "/%C3%A1", "/%C3%A1"),
+        ("/a*b", "/a%2Ab", "/a*b"),
+        ("/a$b", "/a%24b", "/a%24b"),
+        ("/a$", "/a%24", "/a$"),
+        ("https://example.com/a/b";, "/a/b", "/https%3A//example.com/a/b"),
+        ("https://example.com/a b", "/a%20b", "/https%3A//example.com/a%20b"),
+        ("https://example.com//a";, "//a", "/https%3A//example.com//a"),
+        ("https://example.com";, "/", "/https%3A//example.com"),
+    ],
+)
+def test_quoting(value, path, pattern):
+    assert _quote_path(value) == path
+    assert _quote_pattern(value) == pattern
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/protego-0.6.2/tox.ini new/protego-0.7.0/tox.ini
--- old/protego-0.6.2/tox.ini   2020-02-02 01:00:00.000000000 +0100
+++ new/protego-0.7.0/tox.ini   2020-02-02 01:00:00.000000000 +0100
@@ -7,7 +7,34 @@
     pytest-cov >= 7.0.0
     pytest-timeout
 commands =
-    pytest --cov=protego --cov-report=xml --cov-report= {posargs:tests}
+    pytest --cov=protego --cov-report=xml --cov-report= 
--cov-report=term-missing \
+        --junitxml=testenv.junit.xml -o junit_family=legacy \
+        {posargs:tests}
+
+[testenv:benchmarks]
+deps =
+    pytest
+    pytest-codspeed==5.0.3
+# The CodSpeed runner drives pytest-codspeed through CODSPEED_* variables, 
which
+# have to reach the command tox runs for any measurement to happen.
+passenv =
+    *codspeed*
+    *ci*
+# Hash randomization perturbs dict and set behaviour, which shows up as
+# jitter in instruction counts.
+set_env =
+    PYTHONHASHSEED = 0
+commands =
+    pytest --codspeed {posargs:benchmarks/test_benchmarks.py}
+
+[testenv:comparison]
+deps =
+    -r benchmarks/requirements.txt
+    docutils
+    pytest
+commands =
+    pytest benchmarks/test_comparison.py
+    python -m benchmarks.compare {posargs}
 
 [testenv:pre-commit]
 deps = pre-commit
@@ -17,17 +44,17 @@
 [testenv:twinecheck]
 basepython = python3
 deps =
-    twine==6.2.0
-    build==1.4.0
+    twine==7.0.0
+    build==1.5.0
 commands =
     python -m build --sdist
     twine check dist/*
 
-[testenv:typing]
+[testenv:mypy]
 basepython = python3
 deps =
-    mypy==1.19.1
-    pytest
-    scrapy==2.14.1
+    mypy==2.3.1
+    pytest==9.1.1
+    scrapy==2.18.0
 commands =
     mypy {posargs: src tests}

Reply via email to