Skip to content

Commit 314ded7

Browse files
committed
add Pydantic models for borg 1.4's CLI output, incomplete tests (#8338)
1 parent b712d55 commit 314ded7

File tree

4 files changed

+215
-3
lines changed

4 files changed

+215
-3
lines changed

requirements.d/development.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ pytest-cov
1212
pytest-benchmark
1313
Cython
1414
pre-commit
15+
pydantic>=2.0

src/borg/helpers/progress.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import logging
21
import json
2+
import logging
33
import sys
44
import time
5+
import typing
56
from shutil import get_terminal_size
67

78
from ..logger import create_logger
9+
810
logger = create_logger()
911

1012
from .parseformat import ellipsis_truncate
@@ -75,7 +77,7 @@ def __del__(self):
7577
self.logger.removeHandler(self.handler)
7678
self.handler.close()
7779

78-
def output_json(self, *, finished=False, **kwargs):
80+
def output_json(self, *, finished=False, override_time: typing.Optional[float] = None, **kwargs):
7981
assert self.json
8082
if not self.emit:
8183
return
@@ -84,7 +86,7 @@ def output_json(self, *, finished=False, **kwargs):
8486
msgid=self.msgid,
8587
type=self.JSON_TYPE,
8688
finished=finished,
87-
time=time.time(),
89+
time=override_time if override_time is not None else time.time(),
8890
))
8991
print(json.dumps(kwargs), file=sys.stderr, flush=True)
9092

src/borg/public/cli_api/v1.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Pydantic models that can parse borg 1.x's JSON output.
2+
3+
The two top-level models are:
4+
5+
- `BorgLogLine`, which parses any line of borg's logging output,
6+
- all `Borg*Result` classes, which parse the final JSON output of some borg commands.
7+
8+
The different types of log lines are defined in the other models.
9+
"""
10+
11+
import json
12+
import logging
13+
import typing
14+
from datetime import datetime
15+
from pathlib import Path
16+
17+
import pydantic
18+
import typing_extensions
19+
20+
_log = logging.getLogger(__name__)
21+
22+
23+
class BaseBorgLogLine(pydantic.BaseModel):
24+
def get_level(self) -> int:
25+
"""Get the log level for this line as a `logging` level value.
26+
27+
If this is a log message with a levelname, use it.
28+
Otherwise, progress messages get `DEBUG` level, and other messages get `INFO`.
29+
"""
30+
return logging.DEBUG
31+
32+
33+
class ArchiveProgressLogLine(BaseBorgLogLine):
34+
original_size: int
35+
compressed_size: int
36+
deduplicated_size: int
37+
nfiles: int
38+
path: Path
39+
time: float
40+
41+
42+
class FinishedArchiveProgress(BaseBorgLogLine):
43+
"""JSON object printed on stdout when an archive is finished."""
44+
45+
time: float
46+
type: typing.Literal["archive_progress"]
47+
finished: bool
48+
49+
50+
class ProgressMessage(BaseBorgLogLine):
51+
operation: int
52+
msgid: typing.Optional[str]
53+
finished: bool
54+
message: typing.Optional[str]
55+
time: float
56+
57+
58+
class ProgressPercent(BaseBorgLogLine):
59+
operation: int
60+
msgid: str | None = pydantic.Field(None)
61+
finished: bool
62+
message: str | None = pydantic.Field(None)
63+
current: float | None = pydantic.Field(None)
64+
info: list[str] | None = pydantic.Field(None)
65+
total: float | None = pydantic.Field(None)
66+
time: float
67+
68+
@pydantic.model_validator(mode="after")
69+
def fields_depending_on_finished(self) -> typing_extensions.Self:
70+
if self.finished:
71+
if self.message is not None:
72+
raise ValueError("message must be None if finished is True")
73+
if self.current is not None and self.total is not None and self.current != self.total:
74+
raise ValueError("current must be equal to total if finished is True")
75+
if self.info is not None:
76+
raise ValueError("info must be None if finished is True")
77+
if self.total is not None:
78+
raise ValueError("total must be None if finished is True")
79+
return self
80+
81+
82+
class FileStatus(BaseBorgLogLine):
83+
status: str
84+
path: Path
85+
86+
87+
class LogMessage(BaseBorgLogLine):
88+
time: float
89+
levelname: typing.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
90+
name: str
91+
message: str
92+
msgid: typing.Optional[str]
93+
94+
def get_level(self) -> int:
95+
try:
96+
return getattr(logging, self.levelname)
97+
except AttributeError:
98+
_log.warning(
99+
"could not find log level %s, giving the following message WARNING level: %s",
100+
self.levelname,
101+
json.dumps(self),
102+
)
103+
return logging.WARNING
104+
105+
106+
_BorgLogLinePossibleTypes = (
107+
ArchiveProgressLogLine | FinishedArchiveProgress | ProgressMessage | ProgressPercent | FileStatus | LogMessage
108+
)
109+
110+
111+
class BorgLogLine(pydantic.RootModel[_BorgLogLinePossibleTypes]):
112+
"""A log line from Borg with the `--log-json` argument."""
113+
114+
def get_level(self) -> int:
115+
return self.root.get_level()
116+
117+
118+
class _BorgArchive(pydantic.BaseModel):
119+
"""Basic archive attributes."""
120+
121+
name: str
122+
id: str
123+
start: datetime
124+
125+
126+
class _BorgArchiveStatistics(pydantic.BaseModel):
127+
"""Statistics of an archive."""
128+
129+
original_size: int
130+
compressed_size: int
131+
deduplicated_size: int
132+
nfiles: int
133+
134+
135+
class _BorgLimitUsage(pydantic.BaseModel):
136+
"""Usage of borg limits by an archive."""
137+
138+
max_archive_size: float
139+
140+
141+
class _BorgDetailedArchive(_BorgArchive):
142+
"""Archive attributes, as printed by `json info` or `json create`."""
143+
144+
end: datetime
145+
duration: float
146+
stats: _BorgArchiveStatistics
147+
limits: _BorgLimitUsage
148+
command_line: typing.List[str]
149+
chunker_params: typing.Any | None = None
150+
151+
152+
class BorgCreateResult(pydantic.BaseModel):
153+
"""JSON object printed at the end of `borg create`."""
154+
155+
archive: _BorgDetailedArchive
156+
157+
158+
class BorgListResult(pydantic.BaseModel):
159+
"""JSON object printed at the end of `borg list`."""
160+
161+
archives: typing.List[_BorgArchive]
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
5+
import borg.public.cli_api.v1 as v1
6+
from borg.helpers.progress import ProgressIndicatorBase
7+
8+
9+
@pytest.fixture(autouse=True)
10+
def reset_progress_operation_id():
11+
"""Reset ProgressIndicatorBase operation ID counter after each test.
12+
13+
This ensures each test gets predictable operation IDs starting from 1.
14+
"""
15+
yield
16+
ProgressIndicatorBase.operation_id_counter = 0
17+
18+
19+
def test_parse_progress_percent_unfinished():
20+
percent = ProgressIndicatorBase()
21+
percent.json = True
22+
percent.emit = True
23+
override_time = 4567.23
24+
25+
with patch("builtins.print") as mock_print:
26+
percent.output_json(finished=False, current=10, override_time=override_time)
27+
mock_print.assert_called_once()
28+
json_output = mock_print.call_args[0][0]
29+
30+
assert v1.ProgressPercent.model_validate_json(json_output) == v1.ProgressPercent(
31+
operation=1, msgid=None, finished=False, message=None, current=10, info=None, total=None, time=4567.23
32+
)
33+
34+
35+
def test_parse_progress_percent_finished():
36+
percent = ProgressIndicatorBase()
37+
percent.json = True
38+
percent.emit = True
39+
override_time = 4567.23
40+
41+
with patch("builtins.print") as mock_print:
42+
percent.output_json(finished=True, override_time=override_time)
43+
mock_print.assert_called_once()
44+
json_output = mock_print.call_args[0][0]
45+
46+
assert v1.ProgressPercent.model_validate_json(json_output) == v1.ProgressPercent(
47+
operation=1, msgid=None, finished=True, message=None, current=None, info=None, total=None, time=override_time
48+
)

0 commit comments

Comments
 (0)