• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

karellen / kubernator / 18432987062

11 Oct 2025 06:10PM UTC coverage: 75.397% (+0.06%) from 75.342%
18432987062

push

github

web-flow
Merge pull request #85 from karellen/schema_format_validation_not_none

When validating schema format numbers (int32 etc) disallow None

627 of 983 branches covered (63.78%)

Branch coverage included in aggregate %.

3 of 5 new or added lines in 1 file covered. (60.0%)

6 existing lines in 4 files now uncovered.

2456 of 3106 relevant lines covered (79.07%)

4.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.74
/src/main/python/kubernator/api.py
1
# -*- coding: utf-8 -*-
2
#
3
#   Copyright 2020 Express Systems USA, Inc
4
#   Copyright 2021 Karellen, Inc.
5
#
6
#   Licensed under the Apache License, Version 2.0 (the "License");
7
#   you may not use this file except in compliance with the License.
8
#   You may obtain a copy of the License at
9
#
10
#       http://www.apache.org/licenses/LICENSE-2.0
11
#
12
#   Unless required by applicable law or agreed to in writing, software
13
#   distributed under the License is distributed on an "AS IS" BASIS,
14
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
#   See the License for the specific language governing permissions and
16
#   limitations under the License.
17
#
18

19
import fnmatch
6✔
20
import json
6✔
21
import logging
6✔
22
import os
6✔
23
import io
6✔
24
import platform
6✔
25
import re
6✔
26
import sys
6✔
27
import traceback
6✔
28
import urllib.parse
6✔
29
from collections.abc import Callable
6✔
30
from collections.abc import Iterable, MutableSet, Reversible
6✔
31
from enum import Enum
6✔
32
from hashlib import sha256
6✔
33
from io import StringIO as io_StringIO
6✔
34
from pathlib import Path
6✔
35
from shutil import rmtree
6✔
36
from subprocess import CalledProcessError
6✔
37
from types import GeneratorType
6✔
38
from typing import Optional, Union, MutableSequence
6✔
39

40
import requests
6✔
41
import yaml
6✔
42
from diff_match_patch import diff_match_patch
6✔
43
from gevent import sleep
6✔
44
from jinja2 import (Environment,
6✔
45
                    ChainableUndefined,
46
                    make_logging_undefined,
47
                    Template as JinjaTemplate,
48
                    pass_context)
49
from jsonschema import validators
6✔
50
from platformdirs import user_cache_dir
6✔
51

52
from kubernator._json_path import jp  # noqa: F401
6✔
53
from kubernator._k8s_client_patches import (URLLIB_HEADERS_PATCH,
6✔
54
                                            CUSTOM_OBJECT_PATCH_23,
55
                                            CUSTOM_OBJECT_PATCH_25)
56

57
_CACHE_HEADER_TRANSLATION = {"etag": "if-none-match",
6✔
58
                             "last-modified": "if-modified-since"}
59
_CACHE_HEADERS = ("etag", "last-modified")
6✔
60

61

62
class TemplateEngine:
6✔
63
    VARIABLE_START_STRING = "{${"
6✔
64
    VARIABLE_END_STRING = "}$}"
6✔
65

66
    def __init__(self, logger):
6✔
67
        self.template_failures = 0
6✔
68
        self.templates = {}
6✔
69

70
        class CollectingUndefined(ChainableUndefined):
6✔
71
            __slots__ = ()
6✔
72

73
            def __str__(self):
6✔
74
                self.template_failures += 1
×
75
                return super().__str__()
×
76

77
        logging_undefined = make_logging_undefined(
6✔
78
            logger=logger,
79
            base=CollectingUndefined
80
        )
81

82
        @pass_context
6✔
83
        def variable_finalizer(ctx, value):
6✔
84
            normalized_value = str(value)
6✔
85
            if self.VARIABLE_START_STRING in normalized_value and self.VARIABLE_END_STRING in normalized_value:
6✔
86
                value_template_content = sys.intern(normalized_value)
6✔
87
                env: Environment = ctx.environment
6✔
88
                value_template = self.templates.get(value_template_content)
6✔
89
                if not value_template:
6!
90
                    value_template = env.from_string(value_template_content, env.globals)
6✔
91
                    self.templates[value_template_content] = value_template
6✔
92
                return value_template.render(ctx.parent)
6✔
93

94
            return normalized_value
6✔
95

96
        self.env = Environment(variable_start_string=self.VARIABLE_START_STRING,
6✔
97
                               variable_end_string=self.VARIABLE_END_STRING,
98
                               autoescape=False,
99
                               finalize=variable_finalizer,
100
                               undefined=logging_undefined)
101

102
    def from_string(self, template):
6✔
103
        return self.env.from_string(template)
6✔
104

105
    def failures(self):
6✔
106
        return self.template_failures
6✔
107

108

109
def calling_frame_source(depth=2):
6✔
110
    f = traceback.extract_stack(limit=depth + 1)[0]
×
111
    return f"file {f.filename}, line {f.lineno} in {f.name}"
×
112

113

114
def re_filter(name: str, patterns: Iterable[re.Pattern]):
6✔
115
    for pattern in patterns:
6✔
116
        if pattern.match(name):
6✔
117
            return True
6✔
118

119

120
def to_patterns(*patterns):
6✔
121
    return [re.compile(fnmatch.translate(p)) for p in patterns]
×
122

123

124
def scan_dir(logger, path: Path, path_filter: Callable[[os.DirEntry], bool], excludes, includes):
6✔
125
    logger.debug("Scanning %s, excluding %s, including %s", path, excludes, includes)
6✔
126
    with os.scandir(path) as it:  # type: Iterable[os.DirEntry]
6✔
127
        files = {f: f for f in
6✔
128
                 sorted(d.name for d in it if path_filter(d) and not re_filter(d.name, excludes))}
129

130
    for include in includes:
6✔
131
        logger.trace("Considering include %s in %s", include, path)
6✔
132
        for f in list(files.keys()):
6✔
133
            if include.match(f):
6✔
134
                del files[f]
6✔
135
                logger.debug("Selecting %s in %s as it matches %s", f, path, include)
6✔
136
                yield path / f
6✔
137

138

139
class FileType(Enum):
6✔
140
    JSON = (json.load,)
6✔
141
    YAML = (yaml.safe_load_all,)
6✔
142

143
    def __init__(self, func):
6✔
144
        self.func = func
6✔
145

146

147
def _load_file(logger, path: Path, file_type: FileType, source=None,
6✔
148
               template_engine: Optional[TemplateEngine] = None,
149
               template_context: Optional[dict] = None) -> Iterable[dict]:
150
    with open(path, "rb" if not template_engine else "rt") as f:
6✔
151
        try:
6✔
152
            if template_engine:
6✔
153
                raw_data = template_engine.from_string(f.read()).render(template_context)
6✔
154
                f.close()
6✔
155
                f = io.StringIO(raw_data)
6✔
156
            data = file_type.func(f)
6✔
157
            if isinstance(data, GeneratorType):
6✔
158
                data = list(data)
6✔
159
            return data
6✔
160
        except Exception as e:
×
161
            logger.error("Failed parsing %s using %s", source or path, file_type, exc_info=e)
×
162
            raise
×
163

164

165
def _download_remote_file(url, file_name, cache: dict):
6✔
166
    retry_delay = 0
6✔
167
    while True:
5✔
168
        if retry_delay:
6!
169
            sleep(retry_delay)
×
170

171
        with requests.get(url, headers=cache, stream=True) as r:
6!
172
            if r.status_code == 429:
6!
173
                if not retry_delay:
×
174
                    retry_delay = 0.2
×
175
                else:
176
                    retry_delay *= 2.0
×
177
                if retry_delay > 2.5:
×
178
                    retry_delay = 2.5
×
179
                continue
×
180

181
            r.raise_for_status()
6✔
182
            if r.status_code != 304:
6!
183
                with open(file_name, "wb") as out:
×
184
                    for chunk in r.iter_content(chunk_size=65535):
×
185
                        out.write(chunk)
×
186
                return dict(r.headers)
×
187
            else:
188
                return None
6✔
189

190

191
def get_app_cache_dir():
6✔
192
    return Path(user_cache_dir("kubernator", "karellen"))
6✔
193

194

195
def get_cache_dir(category: str, sub_category: str = None):
6✔
196
    cache_dir = get_app_cache_dir() / category
6✔
197
    if sub_category:
6!
198
        cache_dir = cache_dir / sub_category
×
199
    if not cache_dir.exists():
6✔
200
        cache_dir.mkdir(parents=True)
6✔
201

202
    return cache_dir
6✔
203

204

205
def download_remote_file(logger, url: str, category: str = "k8s", sub_category: str = None,
6✔
206
                         downloader=_download_remote_file):
207
    config_dir = get_cache_dir(category, sub_category)
6✔
208

209
    file_name = config_dir / sha256(url.encode("UTF-8")).hexdigest()
6✔
210
    cache_file_name = file_name.with_suffix(".cache")
6✔
211
    logger.trace("Cache file for %s is %s.cache", url, file_name)
6✔
212

213
    cache = {}
6✔
214
    if cache_file_name.exists():
6!
215
        logger.trace("Loading cache file from %s", cache_file_name)
6✔
216
        try:
6✔
217
            with open(cache_file_name, "rb") as cache_f:
6✔
218
                cache = json.load(cache_f)
6✔
219
        except (IOError, ValueError) as e:
×
220
            logger.trace("Failed loading cache file from %s (cleaning up)", cache_file_name, exc_info=e)
×
221
            cache_file_name.unlink(missing_ok=True)
×
222

223
    logger.trace("Downloading %s into %s%s", url, file_name, " (caching)" if cache else "")
6✔
224
    headers = downloader(url, file_name, cache)
6✔
225
    up_to_date = False
6✔
226
    if not headers:
6!
227
        logger.trace("File %s(%s) is up-to-date", url, file_name.name)
6✔
228
        up_to_date = True
6✔
229
    else:
230
        cache = {_CACHE_HEADER_TRANSLATION.get(k.lower(), k): v
×
231
                 for k, v in headers.items()
232
                 if k.lower() in _CACHE_HEADERS}
233

234
        logger.trace("Update cache file in %s: %r", cache_file_name, cache)
×
235
        with open(cache_file_name, "wt") as cache_f:
×
236
            json.dump(cache, cache_f)
×
237

238
    return file_name, up_to_date
6✔
239

240

241
def load_remote_file(logger, url, file_type: FileType, category: str = "k8s", sub_category: str = None,
6✔
242
                     downloader=_download_remote_file):
243
    file_name, _ = download_remote_file(logger, url, category, sub_category, downloader=downloader)
6✔
244
    logger.debug("Loading %s from %s using %s", url, file_name, file_type.name)
6✔
245
    return _load_file(logger, file_name, file_type, url)
6✔
246

247

248
def load_file(logger, path: Path, file_type: FileType, source=None,
6✔
249
              template_engine: Optional[TemplateEngine] = None,
250
              template_context: Optional[dict] = None) -> Iterable[dict]:
251
    logger.debug("Loading %s using %s", source or path, file_type.name)
6✔
252
    return _load_file(logger, path, file_type,
6✔
253
                      source, template_engine, template_context)
254

255

256
def validator_with_defaults(validator_class):
6✔
257
    validate_properties = validator_class.VALIDATORS["properties"]
6✔
258

259
    def set_defaults(validator, properties, instance, schema):
6✔
260
        for property, subschema in properties.items():
6✔
261
            if "default" in subschema:
6✔
262
                instance.setdefault(property, subschema["default"])
6✔
263

264
        for error in validate_properties(validator, properties, instance, schema):
6!
265
            yield error
×
266

267
    return validators.extend(validator_class, {"properties": set_defaults})
6✔
268

269

270
class _PropertyList(MutableSequence):
6✔
271

272
    def __init__(self, seq, read_parent, name):
6✔
273
        self.__read_seq = seq
6✔
274
        self.__read_parent = read_parent
6✔
275
        self.__write_parent = None
6✔
276
        self.__write_seq = None
6✔
277
        self.__name = name
6✔
278

279
    def __iter__(self):
6✔
280
        return self.__read_seq.__iter__()
6✔
281

282
    def __mul__(self, __n):
6✔
283
        return self.__read_seq.__mul__(__n)
×
284

285
    def __rmul__(self, __n):
6✔
286
        return self.__read_seq.__rmul__(__n)
×
287

288
    def __imul__(self, __n):
6✔
289
        return self.__read_seq.__imul__(__n)
×
290

291
    def __contains__(self, __o):
6✔
292
        return self.__read_seq.__contains__(__o)
×
293

294
    def __reversed__(self):
6✔
295
        return self.__read_seq.__reversed__()
6✔
296

297
    def __gt__(self, __x):
6✔
298
        return self.__read_seq.__gt__(__x)
×
299

300
    def __ge__(self, __x):
6✔
301
        return self.__read_seq.__ge__(__x)
×
302

303
    def __lt__(self, __x):
6✔
304
        return self.__read_seq.__lt__(__x)
×
305

306
    def __le__(self, __x):
6✔
307
        return self.__read_seq.__le__(__x)
×
308

309
    def __len__(self):
6✔
310
        return self.__read_seq.__len__()
6✔
311

312
    def count(self, __value):
6✔
313
        return self.__read_seq.count(__value)
×
314

315
    def copy(self):
6✔
316
        while True:
317
            try:
×
318
                return self.__write_seq.copy()
×
319
            except AttributeError:
×
320
                self.__clone()
×
321

322
    def __getitem__(self, __i):
6✔
323
        return self.__read_seq.__getitem__(__i)
6✔
324

325
    def append(self, __object):
6✔
326
        while True:
5✔
327
            try:
6✔
328
                return self.__write_seq.append(__object)
6✔
329
            except AttributeError:
6✔
330
                self.__clone()
6✔
331

332
    def extend(self, __iterable):
6✔
333
        while True:
334
            try:
×
335
                return self.__write_seq.extend(__iterable)
×
336
            except AttributeError:
×
337
                self.__clone()
×
338

339
    def pop(self, __index=None):
6✔
340
        while True:
341
            try:
×
342
                return self.__write_seq.pop(__index)
×
343
            except AttributeError:
×
344
                self.__clone()
×
345

346
    def insert(self, __index, __object):
6✔
347
        while True:
348
            try:
×
349
                return self.__write_seq.insert(__index, __object)
×
350
            except AttributeError:
×
351
                self.__clone()
×
352

353
    def remove(self, __value):
6✔
354
        while True:
355
            try:
×
356
                return self.__write_seq.remove(__value)
×
357
            except AttributeError:
×
358
                self.__clone()
×
359

360
    def sort(self, *, key=None, reverse=False):
6✔
361
        while True:
362
            try:
×
363
                return self.__write_seq.sort(key=key, reverse=reverse)
×
364
            except AttributeError:
×
365
                self.__clone()
×
366

367
    def __setitem__(self, __i, __o):
6✔
368
        while True:
369
            try:
×
370
                return self.__write_seq.__setitem__(__i, __o)
×
371
            except AttributeError:
×
372
                self.__clone()
×
373

374
    def __delitem__(self, __i):
6✔
375
        while True:
376
            try:
×
377
                return self.__write_seq.__delitem__(__i)
×
378
            except AttributeError:
×
379
                self.__clone()
×
380

381
    def __add__(self, __x):
6✔
382
        while True:
383
            try:
×
384
                return self.__write_seq.__add__(__x)
×
385
            except AttributeError:
×
386
                self.__clone()
×
387

388
    def __iadd__(self, __x):
6✔
389
        while True:
390
            try:
×
391
                return self.__write_seq.__iadd__(__x)
×
392
            except AttributeError:
×
393
                self.__clone()
×
394

395
    def clear(self):
6✔
396
        while True:
397
            try:
×
398
                return self.__write_seq.clear()
×
399
            except AttributeError:
×
400
                self.__clone()
6✔
401

402
    def reverse(self):
6✔
403
        while True:
404
            try:
×
405
                return self.__write_seq.reverse()
×
406
            except AttributeError:
×
407
                self.__clone()
×
408

409
    def __clone(self):
6✔
410
        if self.__read_parent == self.__write_parent:
6✔
411
            self.__write_seq = self.__read_seq
6✔
412
        else:
413
            self.__write_seq = self.__read_seq.copy()
6✔
414
            self.__read_seq = self.__write_seq
6✔
415

416
            setattr(self.__write_parent, self.__name, self.__write_seq)
6✔
417

418

419
class PropertyDict:
6✔
420
    def __init__(self, _dict=None, _parent=None):
6✔
421
        self.__dict__["_PropertyDict__dict"] = _dict or {}
6✔
422
        self.__dict__["_PropertyDict__parent"] = _parent
6✔
423

424
    def __getattr__(self, item):
6✔
425
        v = self.__getattr(item)
6✔
426
        if isinstance(v, _PropertyList):
6✔
427
            v._PropertyList__write_parent = self
6✔
428
        return v
6✔
429

430
    def __getattr(self, item):
6✔
431
        try:
6✔
432
            v = self.__dict[item]
6✔
433
            if isinstance(v, list):
6✔
434
                v = _PropertyList(v, self, item)
6✔
435
            return v
6✔
436
        except KeyError:
6✔
437
            parent = self.__parent
6✔
438
            if parent is not None:
6!
439
                return parent.__getattr(item)
6✔
440
            raise AttributeError("no attribute %r" % item) from None
×
441

442
    def __setattr__(self, key, value):
6✔
443
        if key.startswith("_PropertyDict__"):
6!
444
            raise AttributeError("prohibited attribute %r" % key)
×
445
        if isinstance(value, dict):
6✔
446
            parent_dict = None
6✔
447
            if self.__parent is not None:
6✔
448
                try:
6✔
449
                    parent_dict = self.__parent.__getattr__(key)
6✔
450
                    if not isinstance(parent_dict, PropertyDict):
6!
451
                        raise ValueError("cannot override a scalar with a synthetic object for attribute %s", key)
×
452
                except AttributeError:
×
453
                    pass
×
454
            value = PropertyDict(value, _parent=parent_dict)
6✔
455
        self.__dict[key] = value
6✔
456

457
    def __delattr__(self, item):
6✔
458
        del self.__dict[item]
6✔
459

460
    def __len__(self):
6✔
461
        return len(self.__dir__())
×
462

463
    def __getitem__(self, item):
6✔
464
        return self.__dict.__getitem__(item)
×
465

466
    def __setitem__(self, key, value):
6✔
467
        self.__dict.__setitem__(key, value)
×
468

469
    def __delitem__(self, key):
6✔
470
        self.__dict.__delitem__(key)
×
471

472
    def __contains__(self, item):
6✔
473
        try:
6✔
474
            self.__dict[item]
6✔
475
            return True
6✔
476
        except KeyError:
6✔
477
            parent = self.__parent
6✔
478
            if parent is not None:
6✔
479
                return parent.__contains__(item)
6✔
480
            return False
6✔
481

482
    def __dir__(self) -> Iterable[str]:
6✔
483
        result: set[str] = set()
×
484
        result.update(self.__dict.keys())
×
485
        if self.__parent is not None:
×
486
            result.update(self.__parent.__dir__())
×
487
        return result
×
488

489
    def __repr__(self):
6✔
490
        return "PropertyDict[%r]" % self.__dict
×
491

492

493
def config_parent(config: PropertyDict):
6✔
494
    return config._PropertyDict__parent
×
495

496

497
def config_as_dict(config: PropertyDict):
6✔
498
    return {k: config[k] for k in dir(config)}
×
499

500

501
def config_get(config: PropertyDict, key: str, default=None):
6✔
502
    try:
×
503
        return config[key]
×
504
    except KeyError:
×
505
        return default
×
506

507

508
class Globs(MutableSet[Union[str, re.Pattern]]):
6✔
509
    def __init__(self, source: Optional[list[Union[str, re.Pattern]]] = None,
6✔
510
                 immutable=False):
511
        self._immutable = immutable
6✔
512
        if source:
6!
513
            self._list = [self.__wrap__(v) for v in source]
6✔
514
        else:
515
            self._list = []
×
516

517
    def __wrap__(self, item: Union[str, re.Pattern]):
6✔
518
        if isinstance(item, re.Pattern):
6✔
519
            return item
6✔
520
        return re.compile(fnmatch.translate(item))
6✔
521

522
    def __contains__(self, item: Union[str, re.Pattern]):
6✔
523
        return self._list.__contains__(self.__wrap__(item))
×
524

525
    def __iter__(self):
6✔
526
        return self._list.__iter__()
6✔
527

528
    def __len__(self):
6✔
529
        return self._list.__len__()
6✔
530

531
    def add(self, value: Union[str, re.Pattern]):
6✔
532
        if self._immutable:
6!
533
            raise RuntimeError("immutable")
×
534

535
        _list = self._list
6✔
536
        value = self.__wrap__(value)
6✔
537
        if value not in _list:
6!
538
            _list.append(value)
6✔
539

540
    def extend(self, values: Iterable[Union[str, re.Pattern]]):
6✔
541
        for v in values:
×
542
            self.add(v)
×
543

544
    def discard(self, value: Union[str, re.Pattern]):
6✔
545
        if self._immutable:
6!
546
            raise RuntimeError("immutable")
×
547

548
        _list = self._list
6✔
549
        value = self.__wrap__(value)
6✔
550
        if value in _list:
6!
551
            _list.remove(value)
6✔
552

553
    def add_first(self, value: Union[str, re.Pattern]):
6✔
554
        if self._immutable:
×
555
            raise RuntimeError("immutable")
×
556

557
        _list = self._list
×
558
        value = self.__wrap__(value)
×
559
        if value not in _list:
×
560
            _list.insert(0, value)
×
561

562
    def extend_first(self, values: Reversible[Union[str, re.Pattern]]):
6✔
563
        for v in reversed(values):
×
564
            self.add_first(v)
×
565

566
    def __str__(self):
6✔
567
        return self._list.__str__()
6✔
568

569
    def __repr__(self):
6✔
570
        return f"Globs[{self._list}]"
×
571

572

573
class Template:
6✔
574
    def __init__(self, name: str, template: JinjaTemplate, defaults: dict = None, path=None, source=None):
6✔
575
        self.name = name
6✔
576
        self.source = source
6✔
577
        self.path = path
6✔
578
        self.template = template
6✔
579
        self.defaults = defaults
6✔
580

581
    def render(self, context: dict, values: dict):
6✔
582
        variables = {"ktor": context,
6✔
583
                     "values": (self.defaults or {}) | values}
584
        return self.template.render(variables)
6✔
585

586

587
class StringIO:
6✔
588
    def __init__(self, trimmed=True):
6✔
589
        self.write = self.write_trimmed if trimmed else self.write_untrimmed
6✔
590
        self._buf = io_StringIO()
6✔
591

592
    def write_untrimmed(self, line):
6✔
593
        self._buf.write(line)
6✔
594

595
    def write_trimmed(self, line):
6✔
596
        self._buf.write(f"{line}\n")
×
597

598
    def getvalue(self):
6✔
599
        return self._buf.getvalue()
6✔
600

601

602
class StripNL:
6✔
603
    def __init__(self, func):
6✔
604
        self._func = func
6✔
605

606
    def __call__(self, line: str):
6✔
607
        return self._func(line.rstrip("\r\n"))
6✔
608

609

610
def log_level_to_verbosity_count(level: int):
6✔
611
    return int(-level / 10 + 6)
6✔
612

613

614
def clone_url_str(url):
6✔
615
    return urllib.parse.urlunsplit(url[:3] + ("", ""))  # no query or fragment
6✔
616

617

618
def prepend_os_path(path):
6✔
619
    path = str(path)
6✔
620
    paths = os.environ["PATH"].split(os.pathsep)
6✔
621
    if path not in paths:
6!
622
        paths.insert(0, path)
6✔
623
        os.environ["PATH"] = os.pathsep.join(paths)
6✔
624
        return True
6✔
625
    return False
×
626

627

628
_GOLANG_MACHINE = platform.machine().lower()
6✔
629
if _GOLANG_MACHINE == "x86_64":
6!
630
    _GOLANG_MACHINE = "amd64"
6✔
631

632
_GOLANG_OS = platform.system().lower()
6✔
633

634

635
def get_golang_machine():
6✔
636
    return _GOLANG_MACHINE
6✔
637

638

639
def get_golang_os():
6✔
640
    return _GOLANG_OS
6✔
641

642

643
def sha256_file_digest(path):
6✔
644
    h = sha256()
×
645
    with open(path, "rb") as f:
×
646
        h.update(f.read(65535))
×
647
    return h.hexdigest()
×
648

649

650
class Repository:
6✔
651
    logger = logging.getLogger("kubernator.repository")
6✔
652
    git_logger = logger.getChild("git")
6✔
653

654
    def __init__(self, repo, cred_aug=None):
6✔
655
        repo = str(repo)  # in case this is a Path
6✔
656
        url = urllib.parse.urlsplit(repo)
6✔
657

658
        if not url.scheme and not url.netloc and Path(url.path).exists():
6!
659
            url = url._replace(scheme="file")  # In case it's a local repository
6✔
660

661
        self.url = url
6✔
662
        self.url_str = urllib.parse.urlunsplit(url[:4] + ("",))
6✔
663
        self._cred_aug = cred_aug
6✔
664
        self._hash_obj = (url.hostname if url.username or url.password else url.netloc,
6✔
665
                          url.path,
666
                          url.query)
667

668
        self.clone_url = None  # Actual URL components used in cloning operations
6✔
669
        self.clone_url_str = None  # Actual URL string used in cloning operations
6✔
670
        self.ref = None
6✔
671
        self.local_dir = None
6✔
672

673
    def __eq__(self, o: object) -> bool:
6✔
674
        if isinstance(o, Repository):
×
675
            return self._hash_obj == o._hash_obj
×
676

677
    def __hash__(self) -> int:
6✔
678
        return hash(self._hash_obj)
6✔
679

680
    def init(self, logger, context):
6✔
681
        run = context.app.run
6✔
682
        run_capturing_out = context.app.run_capturing_out
6✔
683

684
        url = self.url
6✔
685
        if self._cred_aug:
6!
686
            url = self._cred_aug(url)
6✔
687

688
        self.clone_url = url
6✔
689
        self.clone_url_str = clone_url_str(url)
6✔
690

691
        query = urllib.parse.parse_qs(self.url.query)
6✔
692
        ref = query.get("ref")
6✔
693
        if ref:
6✔
694
            self.ref = ref[0]
6✔
695

696
        config_dir = get_cache_dir("git")
6✔
697

698
        git_cache = config_dir / sha256(self.clone_url_str.encode("UTF-8")).hexdigest()
6✔
699

700
        if git_cache.exists() and git_cache.is_dir() and (git_cache / ".git").exists():
6✔
701
            try:
6✔
702
                run(["git", "status"], None, None, cwd=git_cache).wait()
6✔
703
            except CalledProcessError:
×
704
                rmtree(git_cache)
×
705

706
        self.local_dir = git_cache
6✔
707

708
        stdout_logger = StripNL(self.git_logger.debug)
6✔
709
        stderr_logger = StripNL(self.git_logger.info)
6✔
710
        if git_cache.exists():
6✔
711
            if not self.ref:
6!
712
                ref = run_capturing_out(["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
×
713
                                        stderr_logger, cwd=git_cache).strip()[7:]  # Remove prefix "origin/"
714
            else:
715
                ref = self.ref
6✔
716
            self.logger.info("Using %s%s cached in %s", self.url_str,
6✔
717
                             f"?ref={ref}" if not self.ref else "",
718
                             self.local_dir)
719
            run(["git", "config", "remote.origin.fetch", f"+refs/heads/{ref}:refs/remotes/origin/{ref}"],
6✔
720
                stdout_logger, stderr_logger, cwd=git_cache).wait()
721
            run(["git", "fetch", "-pPt", "--force"], stdout_logger, stderr_logger, cwd=git_cache).wait()
6✔
722
            run(["git", "checkout", ref], stdout_logger, stderr_logger, cwd=git_cache).wait()
6✔
723
            run(["git", "clean", "-f"], stdout_logger, stderr_logger, cwd=git_cache).wait()
6✔
724
            run(["git", "reset", "--hard", ref, "--"], stdout_logger, stderr_logger, cwd=git_cache).wait()
6✔
725
            run(["git", "pull"], stdout_logger, stderr_logger, cwd=git_cache).wait()
6✔
726
        else:
727
            self.logger.info("Initializing %s -> %s", self.url_str, self.local_dir)
6✔
728
            args = (["git", "clone", "--depth", "1",
6✔
729
                     "-" + ("v" * log_level_to_verbosity_count(logger.getEffectiveLevel()))] +
730
                    (["-b", self.ref] if self.ref else []) +
731
                    ["--", self.clone_url_str, str(self.local_dir)])
732
            safe_args = [c if c != self.clone_url_str else self.url_str for c in args]
6✔
733
            run(args, stdout_logger, stderr_logger, safe_args=safe_args).wait()
6✔
734

735
    def cleanup(self):
6✔
736
        if False and self.local_dir:
6!
UNCOV
737
            self.logger.info("Cleaning up %s -> %s", self.url_str, self.local_dir)
UNCOV
738
            rmtree(self.local_dir)
739

740

741
class KubernatorPlugin:
6✔
742
    _name = None
6✔
743

744
    def set_context(self, context):
6✔
745
        raise NotImplementedError
×
746

747
    def register(self, **kwargs):
6✔
748
        pass
6✔
749

750
    def handle_init(self):
6✔
751
        pass
6✔
752

753
    def handle_start(self):
6✔
754
        pass
6✔
755

756
    def handle_before_dir(self, cwd: Path):
6✔
757
        pass
6✔
758

759
    def handle_before_script(self, cwd: Path):
6✔
760
        pass
6✔
761

762
    def handle_after_script(self, cwd: Path):
6✔
763
        pass
6✔
764

765
    def handle_after_dir(self, cwd: Path):
6✔
766
        pass
6✔
767

768
    def handle_apply(self):
6✔
769
        pass
6✔
770

771
    def handle_verify(self):
6✔
772
        pass
6✔
773

774
    def handle_shutdown(self):
6✔
775
        pass
6✔
776

777
    def handle_summary(self):
6✔
778
        pass
6✔
779

780

781
def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_stderr, disable_patching,
6✔
782
                              fallback=False):
783
    cache_dir = get_cache_dir("python")
6✔
784
    package_major_dir = cache_dir / str(package_major)
6✔
785
    package_major_dir_str = str(package_major_dir)
6✔
786
    patch_indicator = package_major_dir / ".patched"
6✔
787

788
    if disable_patching and package_major_dir.exists() and patch_indicator.exists():
6✔
789
        logger.info("Patching is disabled, existing Kubernetes Client %s (%s) was patched - "
6✔
790
                    "deleting current client",
791
                    str(package_major), package_major_dir)
792
        rmtree(package_major_dir)
6✔
793

794
    if not package_major_dir.exists() or not len(os.listdir(package_major_dir)):
6✔
795
        package_major_dir.mkdir(parents=True, exist_ok=True)
6✔
796
        try:
6✔
797
            run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input",
6✔
798
                 "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
799
                 "--target", package_major_dir_str, f"kubernetes>={package_major!s}dev0,<{int(package_major) + 1!s}"],
800
                logger_stdout, logger_stderr)
801
        except CalledProcessError as e:
×
802
            if not fallback and "No matching distribution found for" in e.stderr:
×
803
                logger.warning("Kubernetes Client %s (%s) failed to install because the version wasn't found. "
×
804
                               "Falling back to a client of the previous version - %s",
805
                               str(package_major), package_major_dir, int(package_major) - 1)
806
                return install_python_k8s_client(run,
×
807
                                                 int(package_major) - 1,
808
                                                 logger,
809
                                                 logger_stdout,
810
                                                 logger_stderr,
811
                                                 disable_patching,
812
                                                 fallback=True)
813
            else:
814
                raise
×
815

816
    if not patch_indicator.exists() and not disable_patching:
6✔
817
        if not fallback and not len(os.listdir(package_major_dir)):
6!
818
            # Directory is empty
819
            logger.warning("Kubernetes Client %s (%s) directory is empty - the client was not installed. "
×
820
                           "Falling back to a client of the previous version - %s",
821
                           str(package_major), package_major_dir, int(package_major) - 1)
822

823
            return install_python_k8s_client(run,
×
824
                                             int(package_major) - 1,
825
                                             logger,
826
                                             logger_stdout,
827
                                             logger_stderr,
828
                                             disable_patching,
829
                                             fallback=True)
830

831
        for patch_text, target_file, skip_if_found, min_version, max_version, name in (
6✔
832
                URLLIB_HEADERS_PATCH, CUSTOM_OBJECT_PATCH_23, CUSTOM_OBJECT_PATCH_25):
833
            patch_target = package_major_dir / target_file
6✔
834
            logger.info("Applying patch %s to %s...", name, patch_target)
6✔
835
            if min_version and int(package_major) < min_version:
6✔
836
                logger.info("Skipping patch %s on %s due to package major version %s below minimum %d!",
6✔
837
                            name, patch_target, package_major, min_version)
838
                continue
6✔
839
            if max_version and int(package_major) > max_version:
6✔
840
                logger.info("Skipping patch %s on %s due to package major version %s above maximum %d!",
6✔
841
                            name, patch_target, package_major, max_version)
842
                continue
6✔
843

844
            with open(patch_target, "rt") as f:
6✔
845
                target_file_original = f.read()
6✔
846
            if skip_if_found in target_file_original:
6✔
847
                logger.info("Skipping patch %s on %s, as it already appears to be patched!", name,
6✔
848
                            patch_target)
849
                continue
6✔
850

851
            dmp = diff_match_patch()
6✔
852
            patches = dmp.patch_fromText(patch_text)
6✔
853
            target_file_patched, results = dmp.patch_apply(patches, target_file_original)
6✔
854
            failed_patch = False
6✔
855
            for idx, result in enumerate(results):
6✔
856
                if not result:
6!
857
                    failed_patch = True
×
858
                    msg = ("Failed to apply a patch to Kubernetes Client API %s, hunk #%d, patch: \n%s" % (
×
859
                        patch_target, idx, patches[idx]))
860
                    logger.fatal(msg)
×
861
            if failed_patch:
6!
862
                raise RuntimeError(f"Failed to apply some Kubernetes Client API {patch_target} patches")
×
863

864
            with open(patch_target, "wt") as f:
6✔
865
                f.write(target_file_patched)
6✔
866

867
        patch_indicator.touch(exist_ok=False)
6✔
868

869
    return package_major_dir
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc