1# Copyright 2016 The LUCI Authors. All rights reserved. 2# Use of this source code is governed under the Apache License, Version 2.0 3# that can be found in the LICENSE file. 4 5import collections 6import re 7import string 8 9# third_party/ 10from six.moves import urllib 11 12_STREAM_SEP = '/' 13_ALNUM_CHARS = string.ascii_letters + string.digits 14_VALID_SEG_CHARS = _ALNUM_CHARS + ':_-.' 15_SEGMENT_RE_BASE = r'[a-zA-Z0-9][a-zA-Z0-9:_\-.]*' 16_SEGMENT_RE = re.compile('^' + _SEGMENT_RE_BASE + '$') 17_STREAM_NAME_RE = re.compile('^(' + _SEGMENT_RE_BASE + ')(/' + _SEGMENT_RE_BASE + ')*$') 18_MAX_STREAM_NAME_LENGTH = 4096 19 20_MAX_TAG_KEY_LENGTH = 64 21_MAX_TAG_VALUE_LENGTH = 4096 22 23 24def validate_stream_name(v, maxlen=None): 25 """Verifies that a given stream name is valid. 26 27 Args: 28 v (str): The stream name string. 29 30 31 Raises: 32 ValueError if the stream name is invalid. 33 """ 34 maxlen = maxlen or _MAX_STREAM_NAME_LENGTH 35 if len(v) > maxlen: 36 raise ValueError('Maximum length exceeded (%d > %d)' % (len(v), maxlen)) 37 if _STREAM_NAME_RE.match(v) is None: 38 raise ValueError('Invalid stream name: %r' % v) 39 40 41def validate_tag(key, value): 42 """Verifies that a given tag key/value is valid. 43 44 Args: 45 k (str): The tag key. 46 v (str): The tag value. 47 48 Raises: 49 ValueError if the tag is not valid. 50 """ 51 validate_stream_name(key, maxlen=_MAX_TAG_KEY_LENGTH) 52 validate_stream_name(value, maxlen=_MAX_TAG_VALUE_LENGTH) 53 54 55def normalize_segment(seg, prefix=None): 56 """Given a string, mutate it into a valid segment name. 57 58 This operates by replacing invalid segment name characters with underscores 59 (_) when encountered. 60 61 A special case is when "seg" begins with non-alphanumeric character. In this 62 case, we will prefix it with the "prefix", if one is supplied. Otherwise, 63 raises ValueError. 64 65 See _VALID_SEG_CHARS for all valid characters for a segment. 66 67 Raises: 68 ValueError: If normalization could not be successfully performed. 69 """ 70 if not seg: 71 if prefix is None: 72 raise ValueError('Cannot normalize empty segment with no prefix.') 73 seg = prefix 74 else: 75 76 def replace_if_invalid(ch, first=False): 77 ret = ch if ch in _VALID_SEG_CHARS else '_' 78 if first and ch not in _ALNUM_CHARS: 79 if prefix is None: 80 raise ValueError('Segment has invalid beginning, and no prefix was ' 81 'provided.') 82 return prefix + ret 83 return ret 84 85 seg = ''.join(replace_if_invalid(ch, i == 0) for i, ch in enumerate(seg)) 86 87 if _SEGMENT_RE.match(seg) is None: 88 raise AssertionError('Normalized segment is still invalid: %r' % seg) 89 90 return seg 91 92 93def normalize(v, prefix=None): 94 """Given a string, mutate it into a valid stream name. 95 96 This operates by replacing invalid stream name characters with underscores (_) 97 when encountered. 98 99 A special case is when any segment of "v" begins with an non-alphanumeric 100 character. In this case, we will prefix the segment with the "prefix", if one 101 is supplied. Otherwise, raises ValueError. 102 103 See _STREAM_NAME_RE for a description of a valid stream name. 104 105 Raises: 106 ValueError: If normalization could not be successfully performed. 107 """ 108 normalized = _STREAM_SEP.join( 109 normalize_segment(seg, prefix=prefix) for seg in v.split(_STREAM_SEP)) 110 # Validate the resulting string. 111 validate_stream_name(normalized) 112 return normalized 113 114 115class StreamPath(collections.namedtuple('_StreamPath', ('prefix', 'name'))): 116 """StreamPath is a full stream path. 117 118 This consists of both a stream prefix and a stream name. 119 120 When constructed with parse or make, the stream path must be completely valid. 121 However, invalid stream paths may be constructed by manually instantiation. 122 This can be useful for wildcard query values (e.g., "prefix='foo/*/bar/**'"). 123 """ 124 125 @classmethod 126 def make(cls, prefix, name): 127 """Returns (StreamPath): The validated StreamPath instance. 128 129 Args: 130 prefix (str): the prefix component 131 name (str): the name component 132 133 Raises: 134 ValueError: If path is not a full, valid stream path string. 135 """ 136 inst = cls(prefix=prefix, name=name) 137 inst.validate() 138 return inst 139 140 @classmethod 141 def parse(cls, path): 142 """Returns (StreamPath): The parsed StreamPath instance. 143 144 Args: 145 path (str): the full stream path to parse. 146 147 Raises: 148 ValueError: If path is not a full, valid stream path string. 149 """ 150 parts = path.split('/+/', 1) 151 if len(parts) != 2: 152 raise ValueError('Not a full stream path: [%s]' % (path,)) 153 return cls.make(*parts) 154 155 def validate(self): 156 """Raises: ValueError if this is not a valid stream name.""" 157 try: 158 validate_stream_name(self.prefix) 159 except ValueError as e: 160 raise ValueError('Invalid prefix component [%s]: %s' % ( 161 self.prefix, 162 e, 163 )) 164 165 try: 166 validate_stream_name(self.name) 167 except ValueError as e: 168 raise ValueError('Invalid name component [%s]: %s' % ( 169 self.name, 170 e, 171 )) 172 173 def __str__(self): 174 return '%s/+/%s' % (self.prefix, self.name) 175 176 177def get_logdog_viewer_url(host, project, *stream_paths): 178 """Returns (str): The LogDog viewer URL for the named stream(s). 179 180 Args: 181 host (str): The name of the Coordiantor host. 182 project (str): The project name. 183 stream_paths: A set of StreamPath instances for the stream paths to 184 generate the URL for. 185 """ 186 return urllib.parse.urlunparse(( 187 'https', # Scheme 188 host, # netloc 189 'v/', # path 190 '', # params 191 '&'.join(('s=%s' % (urllib.parse.quote('%s/%s' % (project, path), '')) 192 for path in stream_paths)), # query 193 '', # fragment 194 )) 195