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

karellen / kubernator / 19252312831

11 Nov 2025 01:54AM UTC coverage: 77.467% (+2.1%) from 75.318%
19252312831

Pull #92

github

web-flow
Merge 34555e628 into 1ca3f8432
Pull Request #92: Add assert_plugin to augment register_plugin to indicate dependency

654 of 1001 branches covered (65.33%)

Branch coverage included in aggregate %.

10 of 17 new or added lines in 3 files covered. (58.82%)

13 existing lines in 2 files now uncovered.

2526 of 3104 relevant lines covered (81.38%)

3.25 hits per line

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

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

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

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

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

62

63
def to_json(obj: Union[dict, list]):
4✔
64
    return json.dumps(obj)
4✔
65

66

67
def to_yaml_str(s: str):
4✔
68
    return yaml.safe_dump(s)
4✔
69

70

71
def to_json_yaml_str(obj: Union[dict, list]):
4✔
72
    """
73
    Takes `obj`, dumps as json representation, converts json representation to YAML string literal.
74
    """
75
    return to_yaml_str(to_json(obj))
4✔
76

77

78
def to_yaml_str_block(s: str, indent: int = 4, pretty_indent: int = 2):
4✔
79
    """
80
    Takes a multiline string, dedents it then indents it `indent` spaces for in-yaml alignment and
81
    `pretty-indent` spaces for in-block alignment.
82
    """
83
    return (f"|+{pretty_indent}\n" +
4✔
84
            textwrap.indent(textwrap.dedent(s), " " * (indent + pretty_indent)))
85

86

87
def to_json_yaml_str_block(obj: Union[str, dict, list], indent: int = 4, pretty_indent=2):
4✔
88
    """
89
    Takes an `obj`, serializes it as pretty JSON with `pretty_indent` in-json indentation and then
90
    passes it to `to_yaml_str_block`.
91
    """
92
    return to_yaml_str_block(json.dumps(obj, indent=pretty_indent), indent=indent, pretty_indent=pretty_indent)
4✔
93

94

95
def to_yaml(obj: Union[dict, list], level_indent: int, indent: int):
4✔
96
    s = yaml.safe_dump(obj, indent=indent)
4✔
97
    return "\n" + textwrap.indent(s, " " * level_indent)
4✔
98

99

100
class TemplateEngine:
4✔
101
    VARIABLE_START_STRING = "{${"
4✔
102
    VARIABLE_END_STRING = "}$}"
4✔
103

104
    def __init__(self, logger):
4✔
105
        self.template_failures = 0
4✔
106
        self.templates = {}
4✔
107

108
        class CollectingUndefined(ChainableUndefined):
4✔
109
            __slots__ = ()
4✔
110

111
            def __str__(self):
4✔
112
                self.template_failures += 1
×
113
                return super().__str__()
×
114

115
        logging_undefined = make_logging_undefined(
4✔
116
            logger=logger,
117
            base=CollectingUndefined
118
        )
119

120
        @pass_context
4✔
121
        def variable_finalizer(ctx, value):
4✔
122
            normalized_value = str(value)
4✔
123
            if self.VARIABLE_START_STRING in normalized_value and self.VARIABLE_END_STRING in normalized_value:
4✔
124
                value_template_content = sys.intern(normalized_value)
4✔
125
                env: Environment = ctx.environment
4✔
126
                value_template = self.templates.get(value_template_content)
4✔
127
                if not value_template:
4!
128
                    value_template = env.from_string(value_template_content, env.globals)
4✔
129
                    self.templates[value_template_content] = value_template
4✔
130
                return value_template.render(ctx.parent)
4✔
131

132
            return normalized_value
4✔
133

134
        self.env = Environment(variable_start_string=self.VARIABLE_START_STRING,
4✔
135
                               variable_end_string=self.VARIABLE_END_STRING,
136
                               autoescape=False,
137
                               finalize=variable_finalizer,
138
                               undefined=logging_undefined,
139
                               )
140

141
        self.env.filters["to_json"] = to_json
4✔
142
        self.env.filters["to_yaml_str"] = to_yaml_str
4✔
143
        self.env.filters["to_yaml"] = to_yaml
4✔
144
        self.env.filters["to_yaml_str_block"] = to_yaml_str_block
4✔
145
        self.env.filters["to_json_yaml_str_block"] = to_json_yaml_str_block
4✔
146
        self.env.filters["to_json_yaml_str"] = to_json_yaml_str
4✔
147

148
    def from_string(self, template):
4✔
149
        return self.env.from_string(template)
4✔
150

151
    def failures(self):
4✔
152
        return self.template_failures
4✔
153

154

155
def calling_frame_source(depth=2):
4✔
156
    f = traceback.extract_stack(limit=depth + 1)[0]
×
157
    return f"file {f.filename}, line {f.lineno} in {f.name}"
×
158

159

160
def re_filter(name: str, patterns: Iterable[re.Pattern]):
4✔
161
    for pattern in patterns:
4✔
162
        if pattern.match(name):
4✔
163
            return True
4✔
164

165

166
def to_patterns(*patterns):
4✔
167
    return [re.compile(fnmatch.translate(p)) for p in patterns]
×
168

169

170
def scan_dir(logger, path: Path, path_filter: Callable[[os.DirEntry], bool], excludes, includes):
4✔
171
    logger.debug("Scanning %s, excluding %s, including %s", path, excludes, includes)
4✔
172
    with os.scandir(path) as it:  # type: Iterable[os.DirEntry]
4✔
173
        files = {f: f for f in
4✔
174
                 sorted(d.name for d in it if path_filter(d) and not re_filter(d.name, excludes))}
175

176
    for include in includes:
4✔
177
        logger.trace("Considering include %s in %s", include, path)
4✔
178
        for f in list(files.keys()):
4✔
179
            if include.match(f):
4✔
180
                del files[f]
4✔
181
                logger.debug("Selecting %s in %s as it matches %s", f, path, include)
4✔
182
                yield path / f
4✔
183

184

185
def parse_yaml_docs(document: str, source=None):
4✔
186
    try:
4✔
187
        return list(d for d in yaml.safe_load_all(document) if d)
4✔
188
    except MarkedYAMLError:
×
189
        raise
×
190

191

192
class FileType(Enum):
4✔
193
    TEXT = (lambda x: x,)
4!
194
    BINARY = (lambda x: x,)
4!
195
    JSON = (json.loads,)
4✔
196
    YAML = (parse_yaml_docs,)
4✔
197

198
    def __init__(self, func):
4✔
199
        self.func = func
4✔
200

201

202
def _load_file(logger, path: Path, file_type: FileType, source=None,
4✔
203
               template_engine: Optional[TemplateEngine] = None,
204
               template_context: Optional[dict] = None) -> Iterable[dict]:
205
    with open(path, "rb" if file_type == FileType.BINARY else "rt") as f:
4✔
206
        try:
4✔
207
            if template_engine and not file_type == FileType.BINARY:
4✔
208
                raw_data = template_engine.from_string(f.read()).render(template_context)
4✔
209
            else:
210
                raw_data = f.read()
4✔
211
            data = file_type.func(raw_data)
4✔
212
            if isinstance(data, GeneratorType):
4!
213
                data = list(data)
×
214
            return data
4✔
215
        except Exception as e:
×
216
            logger.error("Failed parsing %s using %s", source or path, file_type, exc_info=e)
×
217
            raise
×
218

219

220
def _download_remote_file(url, file_name, cache: dict):
4✔
221
    retry_delay = 0
4✔
222
    while True:
3✔
223
        if retry_delay:
4!
224
            sleep(retry_delay)
×
225

226
        with requests.get(url, headers=cache, stream=True) as r:
4!
227
            if r.status_code == 429:
4!
228
                if not retry_delay:
×
229
                    retry_delay = 0.2
×
230
                else:
231
                    retry_delay *= 2.0
×
232
                if retry_delay > 2.5:
×
233
                    retry_delay = 2.5
×
234
                continue
×
235

236
            r.raise_for_status()
4✔
237
            if r.status_code != 304:
4!
238
                with open(file_name, "wb") as out:
×
239
                    for chunk in r.iter_content(chunk_size=65535):
×
240
                        out.write(chunk)
×
241
                return dict(r.headers)
×
242
            else:
243
                return None
4✔
244

245

246
def get_app_cache_dir():
4✔
247
    return Path(user_cache_dir("kubernator", "karellen"))
4✔
248

249

250
def get_cache_dir(category: str, sub_category: str = None):
4✔
251
    cache_dir = get_app_cache_dir() / category
4✔
252
    if sub_category:
4!
253
        cache_dir = cache_dir / sub_category
×
254
    if not cache_dir.exists():
4✔
255
        cache_dir.mkdir(parents=True)
4✔
256

257
    return cache_dir
4✔
258

259

260
def download_remote_file(logger, url: str, category: str = "k8s", sub_category: str = None,
4✔
261
                         downloader=_download_remote_file):
262
    config_dir = get_cache_dir(category, sub_category)
4✔
263

264
    file_name = config_dir / sha256(url.encode("UTF-8")).hexdigest()
4✔
265
    cache_file_name = file_name.with_suffix(".cache")
4✔
266
    logger.trace("Cache file for %s is %s.cache", url, file_name)
4✔
267

268
    cache = {}
4✔
269
    if cache_file_name.exists():
4!
270
        logger.trace("Loading cache file from %s", cache_file_name)
4✔
271
        try:
4✔
272
            with open(cache_file_name, "rb") as cache_f:
4✔
273
                cache = json.load(cache_f)
4✔
274
        except (IOError, ValueError) as e:
×
275
            logger.trace("Failed loading cache file from %s (cleaning up)", cache_file_name, exc_info=e)
×
276
            cache_file_name.unlink(missing_ok=True)
×
277

278
    logger.trace("Downloading %s into %s%s", url, file_name, " (caching)" if cache else "")
4✔
279
    headers = downloader(url, file_name, cache)
4✔
280
    up_to_date = False
4✔
281
    if not headers:
4!
282
        logger.trace("File %s(%s) is up-to-date", url, file_name.name)
4✔
283
        up_to_date = True
4✔
284
    else:
285
        cache = {_CACHE_HEADER_TRANSLATION.get(k.lower(), k): v
×
286
                 for k, v in headers.items()
287
                 if k.lower() in _CACHE_HEADERS}
288

289
        logger.trace("Update cache file in %s: %r", cache_file_name, cache)
×
290
        with open(cache_file_name, "wt") as cache_f:
×
291
            json.dump(cache, cache_f)
×
292

293
    return file_name, up_to_date
4✔
294

295

296
def load_remote_file(logger, url, file_type: FileType, category: str = "k8s", sub_category: str = None,
4✔
297
                     downloader=_download_remote_file):
298
    file_name, _ = download_remote_file(logger, url, category, sub_category, downloader=downloader)
4✔
299
    logger.debug("Loading %s from %s using %s", url, file_name, file_type.name)
4✔
300
    return _load_file(logger, file_name, file_type, url)
4✔
301

302

303
def load_file(logger, path: Path, file_type: FileType, source=None,
4✔
304
              template_engine: Optional[TemplateEngine] = None,
305
              template_context: Optional[dict] = None) -> Iterable[dict]:
306
    logger.debug("Loading %s using %s", source or path, file_type.name)
4✔
307
    return _load_file(logger, path, file_type,
4✔
308
                      source, template_engine, template_context)
309

310

311
def validator_with_defaults(validator_class):
4✔
312
    validate_properties = validator_class.VALIDATORS["properties"]
4✔
313

314
    def set_defaults(validator, properties, instance, schema):
4✔
315
        for property, subschema in properties.items():
4✔
316
            if "default" in subschema:
4✔
317
                instance.setdefault(property, subschema["default"])
4✔
318

319
        for error in validate_properties(validator, properties, instance, schema):
4!
320
            yield error
×
321

322
    return validators.extend(validator_class, {"properties": set_defaults})
4✔
323

324

325
class _PropertyList(MutableSequence):
4✔
326

327
    def __init__(self, seq, read_parent, name):
4✔
328
        self.__read_seq = seq
4✔
329
        self.__read_parent = read_parent
4✔
330
        self.__write_parent = None
4✔
331
        self.__write_seq = None
4✔
332
        self.__name = name
4✔
333

334
    def __iter__(self):
4✔
335
        return self.__read_seq.__iter__()
4✔
336

337
    def __mul__(self, __n):
4✔
338
        return self.__read_seq.__mul__(__n)
×
339

340
    def __rmul__(self, __n):
4✔
341
        return self.__read_seq.__rmul__(__n)
×
342

343
    def __imul__(self, __n):
4✔
344
        return self.__read_seq.__imul__(__n)
×
345

346
    def __contains__(self, __o):
4✔
347
        return self.__read_seq.__contains__(__o)
×
348

349
    def __reversed__(self):
4✔
350
        return self.__read_seq.__reversed__()
4✔
351

352
    def __gt__(self, __x):
4✔
353
        return self.__read_seq.__gt__(__x)
×
354

355
    def __ge__(self, __x):
4✔
356
        return self.__read_seq.__ge__(__x)
×
357

358
    def __lt__(self, __x):
4✔
359
        return self.__read_seq.__lt__(__x)
×
360

361
    def __le__(self, __x):
4✔
362
        return self.__read_seq.__le__(__x)
×
363

364
    def __len__(self):
4✔
365
        return self.__read_seq.__len__()
4✔
366

367
    def count(self, __value):
4✔
368
        return self.__read_seq.count(__value)
×
369

370
    def copy(self):
4✔
UNCOV
371
        while True:
372
            try:
×
373
                return self.__write_seq.copy()
×
374
            except AttributeError:
×
375
                self.__clone()
×
376

377
    def __getitem__(self, __i):
4✔
378
        return self.__read_seq.__getitem__(__i)
4✔
379

380
    def append(self, __object):
4✔
381
        while True:
3✔
382
            try:
4✔
383
                return self.__write_seq.append(__object)
4✔
384
            except AttributeError:
4✔
385
                self.__clone()
4✔
386

387
    def extend(self, __iterable):
4✔
UNCOV
388
        while True:
389
            try:
×
390
                return self.__write_seq.extend(__iterable)
×
391
            except AttributeError:
×
392
                self.__clone()
×
393

394
    def pop(self, __index=None):
4✔
UNCOV
395
        while True:
396
            try:
×
397
                return self.__write_seq.pop(__index)
×
398
            except AttributeError:
×
399
                self.__clone()
×
400

401
    def insert(self, __index, __object):
4✔
UNCOV
402
        while True:
403
            try:
×
404
                return self.__write_seq.insert(__index, __object)
×
405
            except AttributeError:
×
406
                self.__clone()
×
407

408
    def remove(self, __value):
4✔
UNCOV
409
        while True:
410
            try:
×
411
                return self.__write_seq.remove(__value)
×
412
            except AttributeError:
×
413
                self.__clone()
×
414

415
    def sort(self, *, key=None, reverse=False):
4✔
UNCOV
416
        while True:
417
            try:
×
418
                return self.__write_seq.sort(key=key, reverse=reverse)
×
419
            except AttributeError:
×
420
                self.__clone()
×
421

422
    def __setitem__(self, __i, __o):
4✔
423
        while True:
3✔
424
            try:
×
425
                return self.__write_seq.__setitem__(__i, __o)
4✔
426
            except AttributeError:
×
427
                self.__clone()
×
428

429
    def __delitem__(self, __i):
4✔
UNCOV
430
        while True:
431
            try:
×
432
                return self.__write_seq.__delitem__(__i)
×
433
            except AttributeError:
×
434
                self.__clone()
×
435

436
    def __add__(self, __x):
4✔
UNCOV
437
        while True:
438
            try:
×
439
                return self.__write_seq.__add__(__x)
×
440
            except AttributeError:
×
441
                self.__clone()
×
442

443
    def __iadd__(self, __x):
4✔
UNCOV
444
        while True:
445
            try:
×
446
                return self.__write_seq.__iadd__(__x)
×
447
            except AttributeError:
×
448
                self.__clone()
×
449

450
    def clear(self):
4✔
UNCOV
451
        while True:
452
            try:
×
453
                return self.__write_seq.clear()
×
454
            except AttributeError:
×
455
                self.__clone()
×
456

457
    def reverse(self):
4✔
UNCOV
458
        while True:
459
            try:
×
460
                return self.__write_seq.reverse()
×
461
            except AttributeError:
×
462
                self.__clone()
×
463

464
    def __clone(self):
4✔
465
        if self.__read_parent == self.__write_parent:
4✔
466
            self.__write_seq = self.__read_seq
4✔
467
        else:
468
            self.__write_seq = self.__read_seq.copy()
4✔
469
            self.__read_seq = self.__write_seq
4✔
470

471
            setattr(self.__write_parent, self.__name, self.__write_seq)
4✔
472

473

474
class PropertyDict:
4✔
475
    def __init__(self, _dict=None, _parent=None):
4✔
476
        self.__dict__["_PropertyDict__dict"] = _dict or {}
4✔
477
        self.__dict__["_PropertyDict__parent"] = _parent
4✔
478

479
    def __getattr__(self, item):
4✔
480
        v = self.__getattr(item)
4✔
481
        if isinstance(v, _PropertyList):
4✔
482
            v._PropertyList__write_parent = self
4✔
483
        return v
4✔
484

485
    def __getattr(self, item):
4✔
486
        try:
4✔
487
            v = self.__dict[item]
4✔
488
            if isinstance(v, list):
4✔
489
                v = _PropertyList(v, self, item)
4✔
490
            return v
4✔
491
        except KeyError:
4✔
492
            parent = self.__parent
4✔
493
            if parent is not None:
4!
494
                return parent.__getattr(item)
4✔
495
            raise AttributeError("no attribute %r" % item) from None
×
496

497
    def __setattr__(self, key, value):
4✔
498
        if key.startswith("_PropertyDict__"):
4!
499
            raise AttributeError("prohibited attribute %r" % key)
×
500
        if isinstance(value, dict):
4✔
501
            parent_dict = None
4✔
502
            if self.__parent is not None:
4✔
503
                try:
4✔
504
                    parent_dict = self.__parent.__getattr__(key)
4✔
505
                    if not isinstance(parent_dict, PropertyDict):
4!
506
                        raise ValueError("cannot override a scalar with a synthetic object for attribute %s", key)
×
507
                except AttributeError:
×
508
                    pass
×
509
            value = PropertyDict(value, _parent=parent_dict)
4✔
510
        self.__dict[key] = value
4✔
511

512
    def __delattr__(self, item):
4✔
513
        del self.__dict[item]
4✔
514

515
    def __len__(self):
4✔
516
        return len(self.__dir__())
×
517

518
    def __getitem__(self, item):
4✔
519
        return self.__dict.__getitem__(item)
×
520

521
    def __setitem__(self, key, value):
4✔
522
        self.__dict.__setitem__(key, value)
×
523

524
    def __delitem__(self, key):
4✔
525
        self.__dict.__delitem__(key)
×
526

527
    def __contains__(self, item):
4✔
528
        try:
4✔
529
            self.__dict[item]
4✔
530
            return True
4✔
531
        except KeyError:
4✔
532
            parent = self.__parent
4✔
533
            if parent is not None:
4✔
534
                return parent.__contains__(item)
4✔
535
            return False
4✔
536

537
    def __dir__(self) -> Iterable[str]:
4✔
538
        result: set[str] = set()
×
539
        result.update(self.__dict.keys())
×
540
        if self.__parent is not None:
×
541
            result.update(self.__parent.__dir__())
×
542
        return result
×
543

544
    def __repr__(self):
4✔
545
        return "PropertyDict[%r]" % self.__dict
×
546

547

548
def config_parent(config: PropertyDict):
4✔
549
    return config._PropertyDict__parent
×
550

551

552
def config_as_dict(config: PropertyDict):
4✔
553
    return {k: config[k] for k in dir(config)}
×
554

555

556
def config_get(config: PropertyDict, key: str, default=None):
4✔
557
    try:
×
558
        return config[key]
×
559
    except KeyError:
×
560
        return default
×
561

562

563
class Globs(MutableSet[Union[str, re.Pattern]]):
4✔
564
    def __init__(self, source: Optional[list[Union[str, re.Pattern]]] = None,
4✔
565
                 immutable=False):
566
        self._immutable = immutable
4✔
567
        if source:
4!
568
            self._list = [self.__wrap__(v) for v in source]
4✔
569
        else:
570
            self._list = []
×
571

572
    def __wrap__(self, item: Union[str, re.Pattern]):
4✔
573
        if isinstance(item, re.Pattern):
4✔
574
            return item
4✔
575
        return re.compile(fnmatch.translate(item))
4✔
576

577
    def __contains__(self, item: Union[str, re.Pattern]):
4✔
578
        return self._list.__contains__(self.__wrap__(item))
×
579

580
    def __iter__(self):
4✔
581
        return self._list.__iter__()
4✔
582

583
    def __len__(self):
4✔
584
        return self._list.__len__()
4✔
585

586
    def add(self, value: Union[str, re.Pattern]):
4✔
587
        if self._immutable:
4!
588
            raise RuntimeError("immutable")
×
589

590
        _list = self._list
4✔
591
        value = self.__wrap__(value)
4✔
592
        if value not in _list:
4!
593
            _list.append(value)
4✔
594

595
    def extend(self, values: Iterable[Union[str, re.Pattern]]):
4✔
596
        for v in values:
×
597
            self.add(v)
×
598

599
    def discard(self, value: Union[str, re.Pattern]):
4✔
600
        if self._immutable:
4!
601
            raise RuntimeError("immutable")
×
602

603
        _list = self._list
4✔
604
        value = self.__wrap__(value)
4✔
605
        if value in _list:
4!
606
            _list.remove(value)
4✔
607

608
    def add_first(self, value: Union[str, re.Pattern]):
4✔
609
        if self._immutable:
×
610
            raise RuntimeError("immutable")
×
611

612
        _list = self._list
×
613
        value = self.__wrap__(value)
×
614
        if value not in _list:
×
615
            _list.insert(0, value)
×
616

617
    def extend_first(self, values: Reversible[Union[str, re.Pattern]]):
4✔
618
        for v in reversed(values):
×
619
            self.add_first(v)
×
620

621
    def __str__(self):
4✔
622
        return self._list.__str__()
4✔
623

624
    def __repr__(self):
4✔
625
        return f"Globs[{self._list}]"
×
626

627

628
class Template:
4✔
629
    def __init__(self, name: str, template: JinjaTemplate, defaults: dict = None, path=None, source=None):
4✔
630
        self.name = name
4✔
631
        self.source = source
4✔
632
        self.path = path
4✔
633
        self.template = template
4✔
634
        self.defaults = defaults
4✔
635

636
    def render(self, context: dict, values: dict):
4✔
637
        variables = {"ktor": context,
4✔
638
                     "values": (self.defaults or {}) | values}
639
        return self.template.render(variables)
4✔
640

641

642
class StringIO:
4✔
643
    def __init__(self, trimmed=True):
4✔
644
        self.write = self.write_trimmed if trimmed else self.write_untrimmed
4✔
645
        self._buf = io_StringIO()
4✔
646

647
    def write_untrimmed(self, line):
4✔
648
        self._buf.write(line)
4✔
649

650
    def write_trimmed(self, line):
4✔
651
        self._buf.write(f"{line}\n")
×
652

653
    def getvalue(self):
4✔
654
        return self._buf.getvalue()
4✔
655

656

657
class StripNL:
4✔
658
    def __init__(self, func):
4✔
659
        self._func = func
4✔
660

661
    def __call__(self, line: str):
4✔
662
        return self._func(line.rstrip("\r\n"))
4✔
663

664

665
def log_level_to_verbosity_count(level: int):
4✔
666
    return int(-level / 10 + 6)
4✔
667

668

669
def clone_url_str(url):
4✔
670
    return urllib.parse.urlunsplit(url[:3] + ("", ""))  # no query or fragment
4✔
671

672

673
def prepend_os_path(path):
4✔
674
    path = str(path)
4✔
675
    paths = os.environ["PATH"].split(os.pathsep)
4✔
676
    if path not in paths:
4!
677
        paths.insert(0, path)
4✔
678
        os.environ["PATH"] = os.pathsep.join(paths)
4✔
679
        return True
4✔
680
    return False
×
681

682

683
_GOLANG_MACHINE = platform.machine().lower()
4✔
684
if _GOLANG_MACHINE == "x86_64":
4!
685
    _GOLANG_MACHINE = "amd64"
4✔
686

687
_GOLANG_OS = platform.system().lower()
4✔
688

689

690
def get_golang_machine():
4✔
691
    return _GOLANG_MACHINE
4✔
692

693

694
def get_golang_os():
4✔
695
    return _GOLANG_OS
4✔
696

697

698
def sha256_file_digest(path):
4✔
699
    h = sha256()
×
700
    with open(path, "rb") as f:
×
701
        h.update(f.read(65535))
×
702
    return h.hexdigest()
×
703

704

705
class Repository:
4✔
706
    logger = logging.getLogger("kubernator.repository")
4✔
707
    git_logger = logger.getChild("git")
4✔
708

709
    def __init__(self, repo, cred_aug=None):
4✔
710
        repo = str(repo)  # in case this is a Path
4✔
711
        url = urllib.parse.urlsplit(repo)
4✔
712

713
        if not url.scheme and not url.netloc and Path(url.path).exists():
4!
714
            url = url._replace(scheme="file")  # In case it's a local repository
4✔
715

716
        self.url = url
4✔
717
        self.url_str = urllib.parse.urlunsplit(url[:4] + ("",))
4✔
718
        self._cred_aug = cred_aug
4✔
719
        self._hash_obj = (url.hostname if url.username or url.password else url.netloc,
4✔
720
                          url.path,
721
                          url.query)
722

723
        self.clone_url = None  # Actual URL components used in cloning operations
4✔
724
        self.clone_url_str = None  # Actual URL string used in cloning operations
4✔
725
        self.ref = None
4✔
726
        self.local_dir = None
4✔
727

728
    def __eq__(self, o: object) -> bool:
4✔
729
        if isinstance(o, Repository):
×
730
            return self._hash_obj == o._hash_obj
×
731

732
    def __hash__(self) -> int:
4✔
733
        return hash(self._hash_obj)
4✔
734

735
    def init(self, logger, context):
4✔
736
        run = context.app.run
4✔
737
        run_capturing_out = context.app.run_capturing_out
4✔
738

739
        url = self.url
4✔
740
        if self._cred_aug:
4!
741
            url = self._cred_aug(url)
4✔
742

743
        self.clone_url = url
4✔
744
        self.clone_url_str = clone_url_str(url)
4✔
745

746
        query = urllib.parse.parse_qs(self.url.query)
4✔
747
        ref = query.get("ref")
4✔
748
        if ref:
4✔
749
            self.ref = ref[0]
4✔
750

751
        config_dir = get_cache_dir("git")
4✔
752

753
        git_cache = config_dir / sha256(self.clone_url_str.encode("UTF-8")).hexdigest()
4✔
754

755
        if git_cache.exists() and git_cache.is_dir() and (git_cache / ".git").exists():
4✔
756
            try:
4✔
757
                run(["git", "status"], None, None, cwd=git_cache).wait()
4✔
758
            except CalledProcessError:
×
759
                rmtree(git_cache)
×
760

761
        self.local_dir = git_cache
4✔
762

763
        stdout_logger = StripNL(self.git_logger.debug)
4✔
764
        stderr_logger = StripNL(self.git_logger.info)
4✔
765
        if git_cache.exists():
4✔
766
            if not self.ref:
4!
767
                ref = run_capturing_out(["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
×
768
                                        stderr_logger, cwd=git_cache).strip()[7:]  # Remove prefix "origin/"
769
            else:
770
                ref = self.ref
4✔
771
            self.logger.info("Using %s%s cached in %s", self.url_str,
4✔
772
                             f"?ref={ref}" if not self.ref else "",
773
                             self.local_dir)
774
            run(["git", "config", "remote.origin.fetch", f"+refs/heads/{ref}:refs/remotes/origin/{ref}"],
4✔
775
                stdout_logger, stderr_logger, cwd=git_cache).wait()
776
            run(["git", "fetch", "-pPt", "--force"], stdout_logger, stderr_logger, cwd=git_cache).wait()
4✔
777
            run(["git", "checkout", ref], stdout_logger, stderr_logger, cwd=git_cache).wait()
4✔
778
            run(["git", "clean", "-f"], stdout_logger, stderr_logger, cwd=git_cache).wait()
4✔
779
            run(["git", "reset", "--hard", ref, "--"], stdout_logger, stderr_logger, cwd=git_cache).wait()
4✔
780
            run(["git", "pull"], stdout_logger, stderr_logger, cwd=git_cache).wait()
4✔
781
        else:
782
            self.logger.info("Initializing %s -> %s", self.url_str, self.local_dir)
4✔
783
            args = (["git", "clone", "--depth", "1",
4✔
784
                     "-" + ("v" * log_level_to_verbosity_count(logger.getEffectiveLevel()))] +
785
                    (["-b", self.ref] if self.ref else []) +
786
                    ["--", self.clone_url_str, str(self.local_dir)])
787
            safe_args = [c if c != self.clone_url_str else self.url_str for c in args]
4✔
788
            run(args, stdout_logger, stderr_logger, safe_args=safe_args).wait()
4✔
789

790
    def cleanup(self):
4✔
791
        if False and self.local_dir:
4!
792
            self.logger.info("Cleaning up %s -> %s", self.url_str, self.local_dir)
793
            rmtree(self.local_dir)
794

795

796
class KubernatorPlugin:
4✔
797
    _name = None
4✔
798

799
    def set_context(self, context):
4✔
800
        raise NotImplementedError
×
801

802
    def register(self, **kwargs):
4✔
803
        pass
4✔
804

805
    def handle_init(self):
4✔
806
        pass
4✔
807

808
    def handle_start(self):
4✔
809
        pass
4✔
810

811
    def handle_before_dir(self, cwd: Path):
4✔
812
        pass
4✔
813

814
    def handle_before_script(self, cwd: Path):
4✔
815
        pass
4✔
816

817
    def handle_after_script(self, cwd: Path):
4✔
818
        pass
4✔
819

820
    def handle_after_dir(self, cwd: Path):
4✔
821
        pass
4✔
822

823
    def handle_apply(self):
4✔
824
        pass
4✔
825

826
    def handle_verify(self):
4✔
827
        pass
4✔
828

829
    def handle_shutdown(self):
4✔
830
        pass
4✔
831

832
    def handle_summary(self):
4✔
833
        pass
4✔
834

835

836
def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_stderr, disable_patching,
4✔
837
                              fallback=False):
838
    cache_dir = get_cache_dir("python")
4✔
839
    package_major_dir = cache_dir / str(package_major)
4✔
840
    package_major_dir_str = str(package_major_dir)
4✔
841
    patch_indicator = package_major_dir / ".patched"
4✔
842

843
    if disable_patching and package_major_dir.exists() and patch_indicator.exists():
4✔
844
        logger.info("Patching is disabled, existing Kubernetes Client %s (%s) was patched - "
4✔
845
                    "deleting current client",
846
                    str(package_major), package_major_dir)
847
        rmtree(package_major_dir)
4✔
848

849
    if not package_major_dir.exists() or not len(os.listdir(package_major_dir)):
4✔
850
        package_major_dir.mkdir(parents=True, exist_ok=True)
4✔
851
        try:
4✔
852
            run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input",
4✔
853
                 "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
854
                 "--target", package_major_dir_str, f"kubernetes>={package_major!s}dev0,<{int(package_major) + 1!s}"],
855
                logger_stdout, logger_stderr)
856
        except CalledProcessError as e:
×
857
            if not fallback and "No matching distribution found for" in e.stderr:
×
858
                logger.warning("Kubernetes Client %s (%s) failed to install because the version wasn't found. "
×
859
                               "Falling back to a client of the previous version - %s",
860
                               str(package_major), package_major_dir, int(package_major) - 1)
861
                return install_python_k8s_client(run,
×
862
                                                 int(package_major) - 1,
863
                                                 logger,
864
                                                 logger_stdout,
865
                                                 logger_stderr,
866
                                                 disable_patching,
867
                                                 fallback=True)
868
            else:
869
                raise
×
870

871
    if not patch_indicator.exists() and not disable_patching:
4✔
872
        if not fallback and not len(os.listdir(package_major_dir)):
4!
873
            # Directory is empty
874
            logger.warning("Kubernetes Client %s (%s) directory is empty - the client was not installed. "
×
875
                           "Falling back to a client of the previous version - %s",
876
                           str(package_major), package_major_dir, int(package_major) - 1)
877

878
            return install_python_k8s_client(run,
×
879
                                             int(package_major) - 1,
880
                                             logger,
881
                                             logger_stdout,
882
                                             logger_stderr,
883
                                             disable_patching,
884
                                             fallback=True)
885

886
        for patch_text, target_file, skip_if_found, min_version, max_version, name in (
4✔
887
                URLLIB_HEADERS_PATCH, CUSTOM_OBJECT_PATCH_23, CUSTOM_OBJECT_PATCH_25):
888
            patch_target = package_major_dir / target_file
4✔
889
            logger.info("Applying patch %s to %s...", name, patch_target)
4✔
890
            if min_version and int(package_major) < min_version:
4✔
891
                logger.info("Skipping patch %s on %s due to package major version %s below minimum %d!",
4✔
892
                            name, patch_target, package_major, min_version)
893
                continue
4✔
894
            if max_version and int(package_major) > max_version:
4✔
895
                logger.info("Skipping patch %s on %s due to package major version %s above maximum %d!",
4✔
896
                            name, patch_target, package_major, max_version)
897
                continue
4✔
898

899
            with open(patch_target, "rt") as f:
4✔
900
                target_file_original = f.read()
4✔
901
            if skip_if_found in target_file_original:
4✔
902
                logger.info("Skipping patch %s on %s, as it already appears to be patched!", name,
4✔
903
                            patch_target)
904
                continue
4✔
905

906
            dmp = diff_match_patch()
4✔
907
            patches = dmp.patch_fromText(patch_text)
4✔
908
            target_file_patched, results = dmp.patch_apply(patches, target_file_original)
4✔
909
            failed_patch = False
4✔
910
            for idx, result in enumerate(results):
4✔
911
                if not result:
4!
912
                    failed_patch = True
×
913
                    msg = ("Failed to apply a patch to Kubernetes Client API %s, hunk #%d, patch: \n%s" % (
×
914
                        patch_target, idx, patches[idx]))
915
                    logger.fatal(msg)
×
916
            if failed_patch:
4!
917
                raise RuntimeError(f"Failed to apply some Kubernetes Client API {patch_target} patches")
×
918

919
            with open(patch_target, "wt") as f:
4✔
920
                f.write(target_file_patched)
4✔
921

922
        patch_indicator.touch(exist_ok=False)
4✔
923

924
    return package_major_dir
4✔
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