# -*- coding: utf-8 -*-
# This file is part of Shuup.
#
# Copyright (c) 2012-2021, Shuup Commerce Inc. All rights reserved.
#
# This source code is licensed under the OSL-3.0 license found in the
# LICENSE file in the root directory of this source tree.
import fnmatch
from django.utils import lru_cache
from django.utils.translation import ugettext_lazy as _
from shuup.utils.django_compat import force_text
PATTERN_SYNTAX_HELP_TEXT = _(
"Comma-separated values or ranges, e.g. A-Z,10000-19000. "
"Use exclamation marks to negate (A-Z,!G will not match G)."
)
[docs]class Pattern(object):
def __init__(self, pattern_text):
"""
Compile a pattern from the given `pattern_text`.
Patterns are comma-separated atoms of the forms:
* `*` -- matches anything
* `text` -- matched directly
* `min-max` -- inclusive range matched lexicographically OR as integers if possible
* `wild*` -- wildcards (asterisks and question marks allowed)
In addition, atoms may be prefixed with `!` to negate them.
For instance, "10-20,!15" would match all strings (or numbers) between 10 and 20, but not 15.
:type pattern_text: str
"""
self.pattern_text = pattern_text
self.positive_pieces = set()
self.negative_pieces = set()
for piece in force_text(self.pattern_text).split(","):
piece = piece.strip()
if not piece:
continue
if piece.startswith("!"):
negate = True
piece = piece[1:].strip()
else:
negate = False
if "-" in piece:
(min, max) = piece.split("-", 1)
piece = (min.strip(), max.strip())
else:
piece = (piece.strip(), piece.strip())
if negate:
self.negative_pieces.add(piece)
else:
self.positive_pieces.add(piece)
[docs] def matches(self, target):
"""
Evaluate this Pattern against the target.
:type target: str
:rtype: bool
"""
target = force_text(target)
if target in self.negative_pieces:
return False
if any(self._test_piece(piece, target) for piece in self.negative_pieces):
return False
if "*" in self.positive_pieces or target in self.positive_pieces:
return True
return any(self._test_piece(piece, target) for piece in self.positive_pieces)
[docs] def get_alphabetical_limits(self):
if self.negative_pieces or self.pattern_text == "*":
return (None, None)
all_values = set()
for min_value, max_value in self.positive_pieces:
all_values.add(min_value)
all_values.add(max_value)
if not all_values:
return (None, None)
return (min(all_values), max(all_values))
[docs] def as_normalized(self):
"""
Return the pattern's source text in a "normalized" form.
:rtype: str
"""
bits = []
for prefix, in_bits in [
("", self.positive_pieces),
("!", self.negative_pieces),
]:
str_bits = []
for min_value, max_value in in_bits:
if min_value != max_value:
str_bits.append("%s%s-%s" % (prefix, min_value, max_value))
else:
str_bits.append("%s%s" % (prefix, min_value))
bits.extend(sorted(str_bits))
return ",".join(bits)
def _test_piece(self, piece, target):
"""
Test if piece matches the target value.
:param piece: Tuple of min and max values
:type target: str
:rtype: bool
"""
min_value, max_value = piece[:2]
if min_value <= target <= max_value:
return True
if min_value.isdigit() and max_value.isdigit() and target.isdigit():
if int(min_value) <= int(target) <= int(max_value):
return True
for value in piece[:2]:
if "*" in value or "?" in value:
if fnmatch.fnmatch(target, value):
return True
@lru_cache.lru_cache()
def _compile_pattern(pattern):
return Pattern(pattern)
[docs]def pattern_matches(pattern, target):
"""
Verify that a `target` string matches the given pattern.
For pattern strings, compiled patterns are cached.
:param pattern: The pattern. Either a pattern string or a Pattern instance
:type pattern: str|Pattern
:param target: Target string to test against.
:type target: str
:return: Whether the test succeeded.
:rtype: bool
"""
if not isinstance(pattern, Pattern):
pattern = _compile_pattern(pattern)
return pattern.matches(target)