Skip to content

Commit c778e52

Browse files
committed
MB-66604 Add collection string parser
Will be used in a subsequent commit for XDCR conflict logging rules Change-Id: I6a6a636cb08c36efa80a68883b53f214e3b23b1b Reviewed-on: https://review.couchbase.org/c/couchbase-cli/+/227958 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Safian Ali <safian.ali@couchbase.com>
1 parent bc027c9 commit c778e52

2 files changed

Lines changed: 239 additions & 1 deletion

File tree

cbmgr.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import tempfile
1616
import time
1717
import traceback
18+
import dataclasses
1819
import urllib.parse
1920
from argparse import SUPPRESS, Action, ArgumentError, ArgumentParser, HelpFormatter
2021
from operator import itemgetter
@@ -518,6 +519,161 @@ def prompt_for_confirmation(question, default=None):
518519
return
519520

520521

522+
class CollectionStringParser:
523+
def __init__(self, inp):
524+
self.inp = inp
525+
self.pos = 0
526+
527+
def pop(self):
528+
if self.pos == len(self.inp):
529+
return None
530+
531+
self.pos += 1
532+
return self.inp[self.pos - 1]
533+
534+
def peek(self):
535+
if self.pos == len(self.inp):
536+
return None
537+
538+
return self.inp[self.pos]
539+
540+
def expect(self, c):
541+
if self.inp[self.pos] != c:
542+
raise ValueError(f"unexpected char {self.inp[self.pos]}, was expecting {c}")
543+
544+
self.pos += 1
545+
546+
def take_until(self, c, accept_eof=False):
547+
s = ''
548+
while True:
549+
got = self.peek()
550+
if got is None:
551+
if accept_eof:
552+
return s
553+
554+
raise ValueError(f"unexpected end, was waiting for a {c} character")
555+
556+
if got == c:
557+
return s
558+
559+
s += self.pop()
560+
561+
def item(self):
562+
start = self.pop()
563+
if start is None:
564+
raise ValueError("unexpected end")
565+
566+
if start in "'\"":
567+
item = self.take_until(start)
568+
self.pop()
569+
return item
570+
571+
return start + self.take_until(".", accept_eof=True)
572+
573+
def run(self):
574+
items = []
575+
for _ in range(3):
576+
item = self.item()
577+
items.append(item)
578+
if self.peek() is None:
579+
return items
580+
581+
self.expect('.')
582+
583+
raise ValueError(f"extra input left: {self.inp[self.pos:]}")
584+
585+
def parse(self, start_at="bucket"):
586+
items = []
587+
try:
588+
items = self.run()
589+
except ValueError as e:
590+
return None, [str(e)]
591+
592+
keys = ["bucket", "scope", "collection"]
593+
if start_at == "scope":
594+
keys = keys[1:]
595+
596+
if len(keys) < len(items):
597+
return None, ["too many items in collection string"]
598+
599+
d = dict(zip(keys, items))
600+
601+
cs = CollectionString(
602+
bucket=d.get("bucket", None),
603+
scope=d.get("scope", None),
604+
collection=d.get("collection", None))
605+
606+
errors = cs.valid()
607+
if errors:
608+
return None, errors
609+
610+
return cs, []
611+
612+
613+
@dataclasses.dataclass
614+
class CollectionString:
615+
bucket: str = None
616+
scope: str = None
617+
collection: str = None
618+
619+
def scope_collection_string(self):
620+
if not self.scope:
621+
return ""
622+
623+
s = self.scope
624+
if self.collection:
625+
s += "." + self.collection
626+
return s
627+
628+
def levels(self):
629+
if not self.bucket:
630+
return 0
631+
632+
if not self.scope:
633+
return 1
634+
635+
if not self.collection:
636+
return 2
637+
638+
return 3
639+
640+
def _valid_name(self, field, name, allowed, disallowed_starting_chars='', max_len=0):
641+
if max_len != 0 and len(name) > max_len:
642+
return [f"{field} is too long"]
643+
644+
for i, c in enumerate(name.lower()):
645+
if i == 0 and c in disallowed_starting_chars:
646+
return [f"{field} names cannot start with {c}"]
647+
648+
if c not in allowed:
649+
return [f"{field} names cannot have {c} in them"]
650+
651+
return []
652+
653+
def valid(self):
654+
errors = []
655+
if self.bucket:
656+
e = self._valid_name("bucket", self.bucket, "abcdefghijklmnopqrstuvwxyz0123456789.%_-", max_len=100)
657+
if e:
658+
errors += e
659+
660+
valid_scope_collection_chars = "abcdefghijklmnopqrstuvwxyz0123456789%_-"
661+
if self.scope:
662+
e = self._valid_name(
663+
"scope", self.scope, valid_scope_collection_chars, disallowed_starting_chars='%_', max_len=251)
664+
if e:
665+
errors += e
666+
667+
if self.collection:
668+
e = self._valid_name(
669+
"collection", self.collection, valid_scope_collection_chars, disallowed_starting_chars='%_',
670+
max_len=251)
671+
if e:
672+
errors += e
673+
674+
return errors
675+
676+
521677
class CLIHelpFormatter(HelpFormatter):
522678
"""Format help with indented section bodies"""
523679

test/test_cbmgr.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import unittest
22

3-
from cbmgr import compare_versions, process_services
3+
from cbmgr import compare_versions, process_services, CollectionString, CollectionStringParser
44

55

66
class TestProcessServices(unittest.TestCase):
@@ -102,3 +102,85 @@ def test_compare_versions(self):
102102
with self.subTest(name):
103103
result = compare_versions(test["version1"], test["version2"])
104104
self.assertEqual(result, test["expected"])
105+
106+
107+
class TestParseCollectionString(unittest.TestCase):
108+
def test_parse_collection_string(self):
109+
tests = {
110+
"EmptyString": {
111+
"input": "",
112+
"error": "unexpected end",
113+
},
114+
"SingleChar": {
115+
"input": "a",
116+
"expected": CollectionString("a"),
117+
},
118+
"AsteriskInBucket": {
119+
"input": "abc*def",
120+
"error": "bucket names cannot have * in them",
121+
},
122+
"UnclosedQuote": {
123+
"input": "'abc",
124+
"error": "unexpected end",
125+
},
126+
"OneItem": {
127+
"input": "abc-%def",
128+
"expected": CollectionString("abc-%def"),
129+
},
130+
"OneItemQuoted": {
131+
"input": "'abc.def'",
132+
"expected": CollectionString("abc.def"),
133+
},
134+
"TwoItems": {
135+
"input": "abc.def",
136+
"expected": CollectionString("abc", "def"),
137+
},
138+
"TwoItemsSecondEmpty": {
139+
"input": "abc.",
140+
"error": "unexpected end",
141+
},
142+
"TwoItemsNoDotAfterQuote": {
143+
"input": "'abc.def'!ghi",
144+
"error": "unexpected char !",
145+
},
146+
"DotInScope": {
147+
"input": "abc.'def.ghi'",
148+
"error": "scope names cannot have . in them",
149+
},
150+
"ThreeItems": {
151+
"input": "abc.def.ghi",
152+
"expected": CollectionString("abc", "def", "ghi"),
153+
},
154+
"DotInCollection": {
155+
"input": "abc.def.'ghi.jkl'",
156+
"error": "collection names cannot have . in them",
157+
},
158+
"ThreeItemsStartAtScope": {
159+
"input": "abc.def.ghi",
160+
"error": "too many items in collection string",
161+
"start_at": "scope",
162+
},
163+
"FourItems": {
164+
"input": "abc.def.ghi.jkl",
165+
"error": "extra input left",
166+
},
167+
"ScopeStartsWithDisallowedChar": {
168+
"input": "abc.%ef.ghi",
169+
"error": "scope names cannot start with %",
170+
},
171+
"CollectionStartsWithDisallowedChar": {
172+
"input": "abc.def._hi",
173+
"error": "collection names cannot start with _",
174+
}
175+
}
176+
177+
for name, test in tests.items():
178+
with self.subTest(name):
179+
start_at = test.get("start_at", "bucket")
180+
result, errors = CollectionStringParser(test["input"]).parse(start_at)
181+
if "error" in test:
182+
self.assertEqual(len(errors), 1)
183+
self.assertIn(test["error"], errors[0])
184+
continue
185+
186+
self.assertEqual(test["expected"], result)

0 commit comments

Comments
 (0)