backer-cli 0.1.1.dev0__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.
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: backer-cli
3
+ Version: 0.1.1.dev0
4
+ Summary: Backup control automation tool
5
+ Author-email: lmriccardo <rlmarca.dev@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/lmriccardo/backer
8
+ Project-URL: Repository, https://github.com/lmriccardo/backer
9
+ Project-URL: Issues, https://github.com/lmriccardo/backer/issues
10
+ Project-URL: Changelog, https://github.com/lmriccardo/backer/releases
11
+ Keywords: backup,rsync,automation,cron,nas
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Topic :: System :: Archiving :: Backup
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: pydantic[email]>=2.6
24
+ Requires-Dist: croniter>=2.0
25
+ Requires-Dist: PyYAML>=6.0
26
+ Requires-Dist: tabulate>=0.9
27
+ Requires-Dist: requests>=2.32
28
+ Requires-Dist: rich>=13.7
29
+ Requires-Dist: aiohttp>=3.13
30
+ Requires-Dist: packaging>=26.0
31
+
32
+ # The Backer CLI application
@@ -0,0 +1 @@
1
+ # The Backer CLI application
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "backer-cli"
7
+ version = "0.1.1-dev"
8
+ description = "Backup control automation tool"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ keywords = ["backup","rsync","automation","cron","nas"]
12
+ authors = [{ name = "lmriccardo", email = "rlmarca.dev@gmail.com" }]
13
+ license = { text = "MIT" }
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: System Administrators",
17
+ "Topic :: System :: Archiving :: Backup",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Operating System :: POSIX :: Linux"
24
+ ]
25
+
26
+ dependencies = [
27
+ "pydantic[email]>=2.6",
28
+ "croniter>=2.0",
29
+ "PyYAML>=6.0",
30
+ "tabulate>=0.9",
31
+ "requests>=2.32",
32
+ "rich>=13.7",
33
+ "aiohttp>=3.13",
34
+ "packaging>=26.0"
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/lmriccardo/backer"
39
+ Repository = "https://github.com/lmriccardo/backer"
40
+ Issues = "https://github.com/lmriccardo/backer/issues"
41
+ Changelog = "https://github.com/lmriccardo/backer/releases"
42
+
43
+ [project.scripts]
44
+ backer = "backer.cli:main"
45
+
46
+ [tool.setuptools]
47
+ package-dir = {"" = "src"}
48
+
49
+ [tool.setuptools.packages.find]
50
+ where=["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0-dev"
@@ -0,0 +1,4 @@
1
+ import backer.cli as cli
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit( cli.main() )
@@ -0,0 +1,21 @@
1
+ from pydantic import BaseModel
2
+
3
+ class ResponseModel(BaseModel): ...
4
+
5
+ class VersionResponse(ResponseModel):
6
+ api : int
7
+ commit : str
8
+ date : str
9
+ go_version : str
10
+ version : str
11
+
12
+ def __str__(self) -> str:
13
+ return (
14
+ f"Daemon Version: backerd {self.version} " +
15
+ f"({self.commit}) built {self.date} " +
16
+ f"go={self.go_version}"
17
+ )
18
+
19
+ class HealthzResponse(ResponseModel):
20
+ ok: bool
21
+ version: VersionResponse
@@ -0,0 +1,82 @@
1
+ import argparse
2
+ import asyncio
3
+ import http
4
+ import aiohttp.client_exceptions as httpexcept
5
+ import backer.jobs as jobsfn
6
+ import backer.http as httputils
7
+ import backer.version as version
8
+
9
+ CLIENT : httputils.BackerClient | None = None
10
+
11
+ class UnreachableDaemonError(Exception):
12
+ def __init__(self) -> None: super().__init__()
13
+ def __str__(self) -> str:
14
+ return "Backer daemon is not reachable or it is not running"
15
+
16
+ async def _exec(parser: argparse.ArgumentParser) -> None:
17
+ # Parse the arguments
18
+ args = parser.parse_args()
19
+
20
+ global CLIENT
21
+ CLIENT = httputils.BackerClient(sock_path=httputils.get_unix_socket())
22
+ await CLIENT.create() # Create the session with the connector
23
+
24
+ # Before performing any command, check if the daemon
25
+ # is reachable by performing a simple healtz request.
26
+ resp = await CLIENT.make_request(httputils.Endpoints.Healthz)
27
+ if resp.code != http.HTTPStatus.OK or not resp.body.ok:
28
+ raise UnreachableDaemonError()
29
+
30
+ # In the health message there is also the api version
31
+ endpoints = httputils.Endpoints()
32
+ endpoints.version(resp.body.version.api)
33
+
34
+ # If the --version option is given just returns the version
35
+ if args.version:
36
+ # Format the version and returns
37
+ print(resp.body.version)
38
+ await version.format_version()
39
+ return
40
+
41
+ # If the version is not given, but there is no
42
+ # command as well, just prints out the help msg
43
+ if args.command is None:
44
+ parser.print_help()
45
+ return 1
46
+
47
+ _ = await args.func(args, CLIENT, endpoints)
48
+ return 0
49
+
50
+ def def_jobs_command(parser: argparse._SubParsersAction) -> None:
51
+ # Top-level subparser
52
+ jobs_parser = parser.add_parser("jobs", help="Manage jobs")
53
+ jobs_subparser = jobs_parser.add_subparsers(dest="jobs_command", required=True)
54
+
55
+ # Jobs create command
56
+ create_parser = jobs_subparser.add_parser("create", help="Create a job")
57
+ create_parser.add_argument("config", help="YAML configuration file")
58
+ create_parser.set_defaults(func=jobsfn.handle_jobs_create)
59
+
60
+ async def async_main() -> int:
61
+ parser = argparse.ArgumentParser(prog="backer", description="Backup automation control tool")
62
+ subparsers = parser.add_subparsers(dest="command")
63
+
64
+ # First add --version option to base parser
65
+ parser.add_argument("--version", required=False, action="store_true", help="Print out the version")
66
+
67
+ def_jobs_command(subparsers)
68
+
69
+ exit_code = 1
70
+
71
+ try:
72
+ exit_code = await _exec(parser)
73
+ except (httpexcept.ClientConnectorError, UnreachableDaemonError):
74
+ print(UnreachableDaemonError())
75
+ except Exception as e:
76
+ print(f"Error: {e} (type: {type(e)})")
77
+
78
+ await CLIENT.release()
79
+ return exit_code
80
+
81
+ def main() -> int:
82
+ return asyncio.run(async_main())
@@ -0,0 +1,124 @@
1
+ """
2
+ Module with some useful functions to communicate
3
+ with the backerd (the backer daemon) using its HTTP
4
+ REST API.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import aiohttp
10
+ import aiohttp.typedefs
11
+ import backer.apitypes as apitypes
12
+
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from dataclasses import dataclass
16
+
17
+ from typing import (
18
+ overload, Dict, Any, TypedDict,
19
+ Unpack, TypeVar, Optional,
20
+ Type
21
+ )
22
+
23
+ _T = TypeVar('_T')
24
+
25
+ SOCK_NAME = "backerd.sock"
26
+
27
+ class _RequestOptions(TypedDict, total=False):
28
+ params: aiohttp.typedefs.Query
29
+ headers: Dict[str, Any]
30
+ body: Dict[str,Any]
31
+
32
+ class HttpMethod(StrEnum):
33
+ NULL = ""
34
+ GET = "GET"
35
+ POST = "POST"
36
+
37
+ @dataclass(frozen=True)
38
+ class Endpoint:
39
+ route : str
40
+ method : HttpMethod = HttpMethod.NULL
41
+ model : Optional[Type[apitypes.ResponseModel]] = None
42
+
43
+ @overload
44
+ def __truediv__(self, other: str) -> 'Endpoint': ...
45
+ @overload
46
+ def __truediv__(self, other: 'Endpoint') -> 'Endpoint': ...
47
+
48
+ def __truediv__(self, other) -> 'Endpoint':
49
+ if not (isinstance(other, str) or isinstance(other, Endpoint)):
50
+ return NotImplemented()
51
+
52
+ if isinstance(other, str):
53
+ # Input strings usually is for a route group, therefore
54
+ # we can leave the model and method to default None, NULL
55
+ return Endpoint(self.route + other)
56
+
57
+ return Endpoint(self.route + other.route, other.method, other.model)
58
+
59
+ def __eq__(self, other: 'Endpoint') -> bool:
60
+ return self.route == other.route \
61
+ and self.method == other.method
62
+
63
+ class Endpoints:
64
+ _Root : Endpoint = Endpoint("/api")
65
+ Version : Endpoint = _Root / Endpoint("/version", HttpMethod.GET, apitypes.VersionResponse)
66
+ Healthz : Endpoint = _Root / Endpoint("/healthz", HttpMethod.GET, apitypes.HealthzResponse)
67
+
68
+ def __init__(self): self._version = "/v1"
69
+ def version(self, v: int) -> None:
70
+ self._version = f"/v{v}"
71
+
72
+ @property
73
+ def _RootVers(self) -> Endpoint: return self._Root / self._version
74
+ @property
75
+ def Jobs(self) -> Endpoint: return self._RootVers / Endpoint("/jobs", HttpMethod.GET)
76
+ @property
77
+ def CreateJob(self) -> Endpoint: return self.Jobs / Endpoint("/create", HttpMethod.POST)
78
+
79
+ @dataclass(frozen=True)
80
+ class _Response[_T]:
81
+ code : int
82
+ body : _T
83
+
84
+ def get_unix_socket() -> str:
85
+ """ Returns the unix socket for backerd """
86
+ basedir = os.getenv("XDG_RUNTIME_DIR")
87
+ return str(Path(basedir) / SOCK_NAME)
88
+
89
+ class BackerClient:
90
+ def __init__(self, sock_path: str) -> None:
91
+ self._sock_path = sock_path
92
+ self._session: aiohttp.ClientSession | None = None
93
+
94
+ async def make_request(
95
+ self, endpoint: Endpoint, **kwargs: Unpack[_RequestOptions]
96
+ ) -> _Response[_T | Dict[str,Any]]:
97
+ assert self._session is not None, "backer client must have a session"
98
+
99
+ resp = await self._session.request(
100
+ method=endpoint.method.value, url=f"http://localhost{endpoint.route}", **kwargs
101
+ )
102
+
103
+ data = await resp.json() # Get the json body from the response
104
+ data = endpoint.model.model_validate(data)
105
+
106
+ custom_resp = _Response(resp.status, data)
107
+ resp.close() # Close the response before leaving
108
+ return custom_resp
109
+
110
+ async def create(self) -> None:
111
+ connector = aiohttp.UnixConnector(path=self._sock_path)
112
+ self._session = aiohttp.ClientSession(connector=connector)
113
+
114
+ async def release(self) -> None:
115
+ if self._session is None: return
116
+ await self._session.close()
117
+ self._session = None
118
+
119
+ async def __aenter__(self) -> 'BackerClient':
120
+ await self.create()
121
+ return self
122
+
123
+ async def __aexit__(self, *args):
124
+ await self.release()
@@ -0,0 +1,9 @@
1
+ import argparse
2
+ import backer.http as httputils
3
+
4
+ async def handle_jobs_create(
5
+ args: argparse.Namespace, client: httputils.BackerClient,
6
+ endpoints: httputils.Endpoints
7
+ ) -> None:
8
+ """ Handle the `backer jobs create <config>` command """
9
+ print("Create job called")
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import aiohttp
4
+
5
+ from packaging.version import Version
6
+ from datetime import datetime
7
+ from dataclasses import dataclass
8
+ from typing import List, Tuple, TypeAlias
9
+ from backer import __version__ as __in_version__
10
+ from importlib.metadata import version, PackageNotFoundError
11
+
12
+ __version__ = __in_version__
13
+ try:
14
+ __version__ = version("backer-cli")
15
+ except PackageNotFoundError:
16
+ ...
17
+
18
+ RELEASE_API_URL = "https://pypi.org/simple/backer-cli/"
19
+
20
+ @dataclass
21
+ class RemoteFileInfo:
22
+ name: str
23
+ upload_time: datetime
24
+
25
+ VersionList : TypeAlias = List[Version]
26
+ FileList : TypeAlias = List[RemoteFileInfo]
27
+
28
+ async def _get_all_versions() -> Tuple[VersionList, FileList] | None:
29
+ """ Get the lastest version of the backupctl project """
30
+ headers = {'Host': 'pypi.org', 'Accept': 'application/vnd.pypi.simple.v1+json'}
31
+ timeout = aiohttp.ClientTimeout(total=10)
32
+
33
+ # Perform the async fetch operation and get back the payload
34
+ async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
35
+ async with session.get(RELEASE_API_URL) as resp:
36
+ if resp.status < 200 or resp.status >= 300: return
37
+ payload = await resp.json()
38
+
39
+ if not isinstance(payload, dict): return
40
+ if not "versions" in payload: return
41
+ if not isinstance(payload["versions"], list): return
42
+ if not "files" in payload: return
43
+
44
+ # First parse all files
45
+ version_list = list(map(Version, payload.get('versions')))
46
+ file_list = []
47
+ for file_data in payload.get("files"):
48
+ if not isinstance(file_data, dict): continue
49
+
50
+ file_name = file_data.get('filename')
51
+ upload_time = file_data.get('upload-time')
52
+
53
+ if not (isinstance(file_name, str) and isinstance(upload_time, str)):
54
+ continue
55
+
56
+ upload_time = datetime.fromisoformat(upload_time.replace("Z", "+00:00"))
57
+ file_list.append(RemoteFileInfo( file_name, upload_time ))
58
+
59
+ return version_list, file_list
60
+
61
+ def _get_latest_release( versions: VersionList ) -> Version:
62
+ """ Returns the lastest release """
63
+ return max( versions )
64
+
65
+ def _get_release_time( version: Version, files: FileList ) -> datetime | None:
66
+ """ Return the date when the version has been released """
67
+ version_str = str(version) # Convert back to string
68
+ for fileitem in files:
69
+ if fileitem.name.startswith(f"backer-{version_str}"):
70
+ return fileitem.upload_time
71
+
72
+ return None
73
+
74
+ async def format_version() -> None:
75
+ """ Format the version and get latest version """
76
+ curr_version = Version(__version__)
77
+ result = await _get_all_versions()
78
+ curr_version_time = None
79
+
80
+ if result is not None:
81
+ versions, files = result
82
+ curr_version_time = _get_release_time( curr_version, files )
83
+
84
+ # Print the current version
85
+ print(f"CLI Version: {curr_version} (", end="")
86
+ curr_t = "Not Yet Released" if curr_version_time is None else str(curr_version_time)
87
+ print(f"{curr_t})")
88
+
89
+ if result is not None:
90
+ last_version = _get_latest_release( versions )
91
+ last_version_time = _get_release_time( last_version, files )
92
+
93
+ # Print if there is a more recent version
94
+ if curr_version < last_version:
95
+ print(
96
+ "!! A new version is available - " +\
97
+ f"{last_version} ({last_version_time}) !!"
98
+ )
99
+
100
+ print("")
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: backer-cli
3
+ Version: 0.1.1.dev0
4
+ Summary: Backup control automation tool
5
+ Author-email: lmriccardo <rlmarca.dev@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/lmriccardo/backer
8
+ Project-URL: Repository, https://github.com/lmriccardo/backer
9
+ Project-URL: Issues, https://github.com/lmriccardo/backer/issues
10
+ Project-URL: Changelog, https://github.com/lmriccardo/backer/releases
11
+ Keywords: backup,rsync,automation,cron,nas
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Topic :: System :: Archiving :: Backup
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: pydantic[email]>=2.6
24
+ Requires-Dist: croniter>=2.0
25
+ Requires-Dist: PyYAML>=6.0
26
+ Requires-Dist: tabulate>=0.9
27
+ Requires-Dist: requests>=2.32
28
+ Requires-Dist: rich>=13.7
29
+ Requires-Dist: aiohttp>=3.13
30
+ Requires-Dist: packaging>=26.0
31
+
32
+ # The Backer CLI application
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/backer/__init__.py
4
+ src/backer/__main__.py
5
+ src/backer/apitypes.py
6
+ src/backer/cli.py
7
+ src/backer/http.py
8
+ src/backer/jobs.py
9
+ src/backer/version.py
10
+ src/backer_cli.egg-info/PKG-INFO
11
+ src/backer_cli.egg-info/SOURCES.txt
12
+ src/backer_cli.egg-info/dependency_links.txt
13
+ src/backer_cli.egg-info/entry_points.txt
14
+ src/backer_cli.egg-info/requires.txt
15
+ src/backer_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ backer = backer.cli:main
@@ -0,0 +1,8 @@
1
+ pydantic[email]>=2.6
2
+ croniter>=2.0
3
+ PyYAML>=6.0
4
+ tabulate>=0.9
5
+ requests>=2.32
6
+ rich>=13.7
7
+ aiohttp>=3.13
8
+ packaging>=26.0