acex-devkit 1.6.2__tar.gz → 1.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/PKG-INFO +1 -1
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/pyproject.toml +1 -1
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/__init__.py +2 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/drivers/base.py +50 -27
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/drivers/base_driver.py +12 -7
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/__init__.py +1 -0
- acex_devkit-1.7.1/src/acex_devkit/models/management_connection.py +16 -0
- acex_devkit-1.7.1/src/acex_devkit/normalizer/__init__.py +22 -0
- acex_devkit-1.7.1/src/acex_devkit/normalizer/base.py +80 -0
- acex_devkit-1.7.1/src/acex_devkit/normalizer/engine.py +162 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/README.md +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/command.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/configdiffer.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/diff.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/drivers/__init__.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/exceptions/__init__.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/acl_model.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/attribute_value.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/composed_configuration.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/container_entry.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/external_value.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/logging.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/ned.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/node_response.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/spanning_tree.py +0 -0
- {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/types/__init__.py +0 -0
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
from typing import Any, Dict
|
|
5
5
|
|
|
6
|
+
from acex_devkit.models.node_response import NodeListItem
|
|
7
|
+
from acex_devkit.models.management_connection import ManagementConnection
|
|
8
|
+
|
|
6
9
|
|
|
7
10
|
class ParserBase(ABC):
|
|
8
11
|
"""Base class for configuration parsers."""
|
|
@@ -38,52 +41,51 @@ class RendererBase(ABC):
|
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
class TransportBase(ABC):
|
|
41
|
-
"""Base class for device transport/communication.
|
|
42
|
-
|
|
43
|
-
@abstractmethod
|
|
44
|
-
def connect(self) -> None:
|
|
45
|
-
"""Establish connection to the device."""
|
|
46
|
-
pass
|
|
44
|
+
"""Base class for device transport/communication.
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
Each method is self-contained — the driver decides internally
|
|
47
|
+
whether to open/close sessions per call, pool connections, or
|
|
48
|
+
make stateless requests.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
node: The node instance (identity, hostname, vendor, os, ned_id)
|
|
52
|
+
connection: Management connection (target_ip, connection_type)
|
|
53
|
+
**kwargs: Future use (credentials, options, etc.)
|
|
54
|
+
"""
|
|
56
55
|
|
|
57
56
|
@abstractmethod
|
|
58
|
-
def
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
Returns:
|
|
62
|
-
True if verification succeeded, False otherwise
|
|
63
|
-
"""
|
|
57
|
+
def get_config(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> str:
|
|
58
|
+
"""Fetch the full running configuration from a device."""
|
|
64
59
|
pass
|
|
65
60
|
|
|
66
61
|
@abstractmethod
|
|
67
|
-
def
|
|
68
|
-
"""
|
|
62
|
+
def send_config(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> str:
|
|
63
|
+
"""Apply configuration commands to a device."""
|
|
69
64
|
pass
|
|
70
65
|
|
|
66
|
+
def execute(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> list[str]:
|
|
67
|
+
"""Run arbitrary commands and return output per command. Opt-in per driver."""
|
|
68
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement execute()")
|
|
69
|
+
|
|
71
70
|
|
|
72
71
|
class NetworkElementDriver:
|
|
73
72
|
"""Base class for network element drivers.
|
|
74
|
-
|
|
73
|
+
|
|
75
74
|
Combines renderer, transport, and parser to provide complete
|
|
76
75
|
configuration management for network devices.
|
|
77
|
-
|
|
76
|
+
|
|
78
77
|
Attributes:
|
|
79
78
|
renderer_class: Renderer class to use (must be set in subclass)
|
|
80
79
|
transport_class: Transport class to use (must be set in subclass)
|
|
81
80
|
parser_class: Parser class to use (must be set in subclass)
|
|
81
|
+
normalizer_class: Optional normalizer class for stripping
|
|
82
|
+
non-intent config and masking secrets
|
|
82
83
|
"""
|
|
83
|
-
|
|
84
|
+
|
|
84
85
|
renderer_class = None
|
|
85
86
|
transport_class = None
|
|
86
87
|
parser_class = None
|
|
88
|
+
normalizer_class = None
|
|
87
89
|
|
|
88
90
|
def __init__(self):
|
|
89
91
|
"""Initialize driver with renderer, transport, and parser instances."""
|
|
@@ -94,6 +96,7 @@ class NetworkElementDriver:
|
|
|
94
96
|
self.renderer = self.renderer_class()
|
|
95
97
|
self.transport = self.transport_class()
|
|
96
98
|
self.parser = self.parser_class()
|
|
99
|
+
self.normalizer = self.normalizer_class() if self.normalizer_class else None
|
|
97
100
|
|
|
98
101
|
@abstractmethod
|
|
99
102
|
def render(self, logical_node: "LogicalNode", asset: Any = None) -> Any:
|
|
@@ -111,11 +114,31 @@ class NetworkElementDriver:
|
|
|
111
114
|
@abstractmethod
|
|
112
115
|
def parse(self, configuration: str) -> Any:
|
|
113
116
|
"""Parse device configuration.
|
|
114
|
-
|
|
117
|
+
|
|
115
118
|
Args:
|
|
116
119
|
configuration: Raw device configuration
|
|
117
|
-
|
|
120
|
+
|
|
118
121
|
Returns:
|
|
119
122
|
Parsed configuration model
|
|
120
123
|
"""
|
|
121
124
|
return self.parser.parse(configuration)
|
|
125
|
+
|
|
126
|
+
def normalize(self, raw: str) -> str:
|
|
127
|
+
"""Strip non-intent data (timestamps, auto-generated certs, etc.).
|
|
128
|
+
|
|
129
|
+
Returns the cleaned config string. If no normalizer is configured
|
|
130
|
+
the input is returned unchanged.
|
|
131
|
+
"""
|
|
132
|
+
if self.normalizer is None:
|
|
133
|
+
return raw
|
|
134
|
+
return self.normalizer.normalize(raw).config
|
|
135
|
+
|
|
136
|
+
def mask(self, raw: str) -> str:
|
|
137
|
+
"""Replace secrets with <REDACTED>.
|
|
138
|
+
|
|
139
|
+
Returns the masked config string. If no normalizer is configured
|
|
140
|
+
the input is returned unchanged.
|
|
141
|
+
"""
|
|
142
|
+
if self.normalizer is None:
|
|
143
|
+
return raw
|
|
144
|
+
return self.normalizer.mask(raw).config
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
2
|
from typing import Any, Dict
|
|
3
3
|
|
|
4
|
+
from acex_devkit.models.node_response import NodeListItem
|
|
5
|
+
from acex_devkit.models.management_connection import ManagementConnection
|
|
6
|
+
|
|
4
7
|
class ParserBase(ABC):
|
|
5
8
|
@abstractmethod
|
|
6
9
|
def parse(self, model: Dict[str, Any]) -> Any:
|
|
@@ -14,16 +17,18 @@ class RendererBase(ABC):
|
|
|
14
17
|
|
|
15
18
|
class TransportBase(ABC):
|
|
16
19
|
@abstractmethod
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def send(self, payload: Any) -> None: ...
|
|
20
|
+
def get_config(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> str:
|
|
21
|
+
"""Fetch the full running configuration from a device."""
|
|
22
|
+
pass
|
|
21
23
|
|
|
22
24
|
@abstractmethod
|
|
23
|
-
def
|
|
25
|
+
def send_config(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> str:
|
|
26
|
+
"""Apply configuration commands to a device."""
|
|
27
|
+
pass
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
def execute(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> list[str]:
|
|
30
|
+
"""Run arbitrary commands. Opt-in per driver."""
|
|
31
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement execute()")
|
|
27
32
|
|
|
28
33
|
class NetworkElementDriver:
|
|
29
34
|
"""Kombinerar renderer + transport – exponeras som en plugin."""
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
from .external_value import ExternalValue
|
|
7
7
|
from .attribute_value import AttributeValue
|
|
8
8
|
from .node_response import NodeResponse, NodeListItem, LogicalNodeResponse
|
|
9
|
+
from .management_connection import ManagementConnection, ConnectionType
|
|
9
10
|
|
|
10
11
|
__all__ = [
|
|
11
12
|
ExternalValue,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConnectionType(str, Enum):
|
|
7
|
+
ssh = "ssh"
|
|
8
|
+
telnet = "telnet"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ManagementConnection(BaseModel):
|
|
12
|
+
id: Optional[int] = None
|
|
13
|
+
node_id: Optional[int] = None
|
|
14
|
+
primary: bool = True
|
|
15
|
+
connection_type: ConnectionType = ConnectionType.ssh
|
|
16
|
+
target_ip: Optional[str] = None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Configuration normalizer — vendor-agnostic engine for stripping
|
|
2
|
+
runtime noise and masking secrets from device configurations."""
|
|
3
|
+
|
|
4
|
+
from acex_devkit.normalizer.engine import (
|
|
5
|
+
LineRule,
|
|
6
|
+
BlockRule,
|
|
7
|
+
RewriteRule,
|
|
8
|
+
OpStats,
|
|
9
|
+
OpResult,
|
|
10
|
+
NormalizerEngine,
|
|
11
|
+
)
|
|
12
|
+
from acex_devkit.normalizer.base import BaseNormalizer
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"LineRule",
|
|
16
|
+
"BlockRule",
|
|
17
|
+
"RewriteRule",
|
|
18
|
+
"OpStats",
|
|
19
|
+
"OpResult",
|
|
20
|
+
"NormalizerEngine",
|
|
21
|
+
"BaseNormalizer",
|
|
22
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Base normalizer for vendor-specific subclasses.
|
|
2
|
+
|
|
3
|
+
Subclasses only need to declare rules:
|
|
4
|
+
|
|
5
|
+
class MyVendorNormalizer(BaseNormalizer):
|
|
6
|
+
line_rules = [...]
|
|
7
|
+
block_rules = [...]
|
|
8
|
+
rewrite_rules = [...]
|
|
9
|
+
|
|
10
|
+
Optional overrides if the vendor syntax differs:
|
|
11
|
+
is_block_continuation(line) -> bool
|
|
12
|
+
is_block_terminator(line) -> bool
|
|
13
|
+
post_process(lines, stats) -> list[str]
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from acex_devkit.normalizer.engine import (
|
|
21
|
+
LineRule,
|
|
22
|
+
BlockRule,
|
|
23
|
+
RewriteRule,
|
|
24
|
+
OpStats,
|
|
25
|
+
OpResult,
|
|
26
|
+
NormalizerEngine,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseNormalizer:
|
|
31
|
+
line_rules: list[LineRule] = []
|
|
32
|
+
block_rules: list[BlockRule] = []
|
|
33
|
+
rewrite_rules: list[RewriteRule] = []
|
|
34
|
+
|
|
35
|
+
engine: NormalizerEngine = NormalizerEngine()
|
|
36
|
+
|
|
37
|
+
# ── hooks (override per vendor) ───────────────────────────────
|
|
38
|
+
|
|
39
|
+
def is_block_continuation(self, line: str) -> bool:
|
|
40
|
+
if not line:
|
|
41
|
+
return False
|
|
42
|
+
if line.startswith((" ", "\t")):
|
|
43
|
+
return True
|
|
44
|
+
if re.fullmatch(r"[0-9A-Fa-f ]+", line.rstrip()):
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def is_block_terminator(self, line: str) -> bool:
|
|
49
|
+
return line.strip() in ("quit", "exit")
|
|
50
|
+
|
|
51
|
+
def post_process(self, lines: list[str], stats: OpStats) -> list[str]:
|
|
52
|
+
return self.engine.collapse_and_trim(lines, stats)
|
|
53
|
+
|
|
54
|
+
# ── operations ────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def normalize(self, raw: str) -> OpResult:
|
|
57
|
+
lines, stats = self.engine.normalize_pass(
|
|
58
|
+
raw,
|
|
59
|
+
line_rules=self.line_rules,
|
|
60
|
+
block_rules=self.block_rules,
|
|
61
|
+
is_block_continuation=self.is_block_continuation,
|
|
62
|
+
is_block_terminator=self.is_block_terminator,
|
|
63
|
+
)
|
|
64
|
+
lines = self.post_process(lines, stats)
|
|
65
|
+
stats.lines_out = len(lines)
|
|
66
|
+
return OpResult(
|
|
67
|
+
config="\n".join(lines) + ("\n" if lines else ""),
|
|
68
|
+
operation="normalize",
|
|
69
|
+
stats=stats,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def mask(self, raw: str) -> OpResult:
|
|
73
|
+
lines, stats = self.engine.mask_pass(
|
|
74
|
+
raw, rewrite_rules=self.rewrite_rules
|
|
75
|
+
)
|
|
76
|
+
return OpResult(
|
|
77
|
+
config="\n".join(lines) + ("\n" if lines else ""),
|
|
78
|
+
operation="mask",
|
|
79
|
+
stats=stats,
|
|
80
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Stateless rule engine and data types for config normalization.
|
|
2
|
+
|
|
3
|
+
Two independent passes:
|
|
4
|
+
normalize_pass — drops lines/blocks that are not intent-config
|
|
5
|
+
mask_pass — rewrites lines to redact secrets
|
|
6
|
+
|
|
7
|
+
The passes are isolated: normalize never touches content it keeps,
|
|
8
|
+
mask never drops lines. This makes the operations order-independent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Rule types ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class LineRule:
|
|
21
|
+
"""Matches a full line → the line is dropped."""
|
|
22
|
+
name: str
|
|
23
|
+
pattern: re.Pattern
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class BlockRule:
|
|
28
|
+
"""Matches a block header → header + indented body are dropped."""
|
|
29
|
+
name: str
|
|
30
|
+
pattern: re.Pattern
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RewriteRule:
|
|
35
|
+
"""Rewrites a line (e.g. redact secrets)."""
|
|
36
|
+
name: str
|
|
37
|
+
pattern: re.Pattern
|
|
38
|
+
replacement: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Result types ──────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class OpStats:
|
|
45
|
+
lines_in: int = 0
|
|
46
|
+
lines_out: int = 0
|
|
47
|
+
lines_dropped: int = 0
|
|
48
|
+
blocks_dropped: int = 0
|
|
49
|
+
lines_redacted: int = 0
|
|
50
|
+
by_rule: dict[str, int] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def _bump(self, rule: str) -> None:
|
|
53
|
+
self.by_rule[rule] = self.by_rule.get(rule, 0) + 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class OpResult:
|
|
58
|
+
config: str
|
|
59
|
+
operation: str # "normalize" or "mask"
|
|
60
|
+
stats: OpStats
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Engine ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
class NormalizerEngine:
|
|
66
|
+
"""Stateless rule engine used by BaseNormalizer subclasses."""
|
|
67
|
+
|
|
68
|
+
def normalize_pass(
|
|
69
|
+
self,
|
|
70
|
+
raw: str,
|
|
71
|
+
*,
|
|
72
|
+
line_rules: list[LineRule],
|
|
73
|
+
block_rules: list[BlockRule],
|
|
74
|
+
is_block_continuation,
|
|
75
|
+
is_block_terminator,
|
|
76
|
+
) -> tuple[list[str], OpStats]:
|
|
77
|
+
stats = OpStats()
|
|
78
|
+
lines = raw.splitlines()
|
|
79
|
+
stats.lines_in = len(lines)
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
|
|
82
|
+
i = 0
|
|
83
|
+
while i < len(lines):
|
|
84
|
+
line = lines[i]
|
|
85
|
+
|
|
86
|
+
block_hit = next(
|
|
87
|
+
(r for r in block_rules if r.pattern.match(line)), None
|
|
88
|
+
)
|
|
89
|
+
if block_hit:
|
|
90
|
+
stats.blocks_dropped += 1
|
|
91
|
+
stats._bump(f"block:{block_hit.name}")
|
|
92
|
+
stats.lines_dropped += 1
|
|
93
|
+
i += 1
|
|
94
|
+
while i < len(lines) and (
|
|
95
|
+
is_block_continuation(lines[i])
|
|
96
|
+
or is_block_terminator(lines[i])
|
|
97
|
+
):
|
|
98
|
+
stats.lines_dropped += 1
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
line_hit = next(
|
|
103
|
+
(r for r in line_rules if r.pattern.match(line)), None
|
|
104
|
+
)
|
|
105
|
+
if line_hit:
|
|
106
|
+
stats.lines_dropped += 1
|
|
107
|
+
stats._bump(f"line:{line_hit.name}")
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
out.append(line)
|
|
112
|
+
i += 1
|
|
113
|
+
|
|
114
|
+
return out, stats
|
|
115
|
+
|
|
116
|
+
def mask_pass(
|
|
117
|
+
self,
|
|
118
|
+
raw: str,
|
|
119
|
+
*,
|
|
120
|
+
rewrite_rules: list[RewriteRule],
|
|
121
|
+
) -> tuple[list[str], OpStats]:
|
|
122
|
+
stats = OpStats()
|
|
123
|
+
lines = raw.splitlines()
|
|
124
|
+
stats.lines_in = len(lines)
|
|
125
|
+
out: list[str] = []
|
|
126
|
+
|
|
127
|
+
for line in lines:
|
|
128
|
+
rewritten = line
|
|
129
|
+
for rule in rewrite_rules:
|
|
130
|
+
new = rule.pattern.sub(rule.replacement, rewritten)
|
|
131
|
+
if new != rewritten:
|
|
132
|
+
stats.lines_redacted += 1
|
|
133
|
+
stats._bump(f"redact:{rule.name}")
|
|
134
|
+
rewritten = new
|
|
135
|
+
break
|
|
136
|
+
out.append(rewritten)
|
|
137
|
+
|
|
138
|
+
stats.lines_out = len(out)
|
|
139
|
+
return out, stats
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def collapse_and_trim(
|
|
143
|
+
lines: list[str],
|
|
144
|
+
stats: OpStats,
|
|
145
|
+
comment_marker: str = "!",
|
|
146
|
+
) -> list[str]:
|
|
147
|
+
collapsed: list[str] = []
|
|
148
|
+
prev_marker = False
|
|
149
|
+
for line in lines:
|
|
150
|
+
is_marker = line.strip() == comment_marker
|
|
151
|
+
if is_marker and prev_marker:
|
|
152
|
+
stats.lines_dropped += 1
|
|
153
|
+
stats._bump("post:collapse_comment")
|
|
154
|
+
continue
|
|
155
|
+
collapsed.append(line)
|
|
156
|
+
prev_marker = is_marker
|
|
157
|
+
|
|
158
|
+
while collapsed and collapsed[0].strip() in ("", comment_marker):
|
|
159
|
+
collapsed.pop(0)
|
|
160
|
+
while collapsed and collapsed[-1].strip() in ("", comment_marker):
|
|
161
|
+
collapsed.pop()
|
|
162
|
+
return collapsed
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|