mcserver-cli 3.0.0__py3-none-any.whl

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.
Files changed (61) hide show
  1. mcserver/__init__.py +1 -0
  2. mcserver/__main__.py +3 -0
  3. mcserver/app.py +24 -0
  4. mcserver/caching.py +0 -0
  5. mcserver/cli.py +326 -0
  6. mcserver/commands/__init__.py +39 -0
  7. mcserver/commands/add_projects.py +7 -0
  8. mcserver/commands/import_setup.py +6 -0
  9. mcserver/commands/initialize_server.py +119 -0
  10. mcserver/commands/list_projects.py +6 -0
  11. mcserver/commands/op_grant.py +7 -0
  12. mcserver/commands/op_list.py +32 -0
  13. mcserver/commands/op_revoke.py +7 -0
  14. mcserver/commands/print_help.py +7 -0
  15. mcserver/commands/remove_projects.py +6 -0
  16. mcserver/commands/search_projects.py +7 -0
  17. mcserver/commands/server_status.py +6 -0
  18. mcserver/commands/show_projects.py +26 -0
  19. mcserver/commands/start_server.py +150 -0
  20. mcserver/commands/stop_server.py +46 -0
  21. mcserver/commands/update_projects.py +6 -0
  22. mcserver/commands/whitelist_add.py +7 -0
  23. mcserver/commands/whitelist_list.py +31 -0
  24. mcserver/commands/whitelist_remove.py +31 -0
  25. mcserver/commandsa.py +1297 -0
  26. mcserver/config.py +37 -0
  27. mcserver/constants.py +14 -0
  28. mcserver/fabricmc/meta.py +41 -0
  29. mcserver/legacyfabric/meta.py +37 -0
  30. mcserver/metadata.py +33 -0
  31. mcserver/minecraft/api.py +94 -0
  32. mcserver/minecraft/player_identity.py +21 -0
  33. mcserver/modrinth/api.py +657 -0
  34. mcserver/modrinth/modpack.py +101 -0
  35. mcserver/modrinth/project_records/metadata.py +0 -0
  36. mcserver/modrinth/project_records/relationships.py +203 -0
  37. mcserver/modrinth/project_records/shared.py +9 -0
  38. mcserver/modrinth/projects.py +80 -0
  39. mcserver/modrinth/shared.py +13 -0
  40. mcserver/modrinth/slug_index.py +36 -0
  41. mcserver/mojang/eula.py +28 -0
  42. mcserver/mojang/manifest.py +69 -0
  43. mcserver/mysterious.py +5 -0
  44. mcserver/networking.py +152 -0
  45. mcserver/papermc/api.py +81 -0
  46. mcserver/purpurmc/api.py +81 -0
  47. mcserver/quiltmc/meta.py +54 -0
  48. mcserver/resolver.py +164 -0
  49. mcserver/server_files/banned_ips.py +120 -0
  50. mcserver/server_files/banned_players.py +166 -0
  51. mcserver/server_files/ops.py +143 -0
  52. mcserver/server_files/properties.py +41 -0
  53. mcserver/server_files/usercaches.py +0 -0
  54. mcserver/server_files/whitelist.py +163 -0
  55. mcserver/shared.py +198 -0
  56. mcserver/state.py +77 -0
  57. mcserver_cli-3.0.0.dist-info/METADATA +63 -0
  58. mcserver_cli-3.0.0.dist-info/RECORD +61 -0
  59. mcserver_cli-3.0.0.dist-info/WHEEL +4 -0
  60. mcserver_cli-3.0.0.dist-info/entry_points.txt +2 -0
  61. mcserver_cli-3.0.0.dist-info/licenses/LICENSE +9 -0
mcserver/networking.py ADDED
@@ -0,0 +1,152 @@
1
+ # Modules
2
+ # Standard
3
+ import hashlib
4
+ import logging as log
5
+ import shutil
6
+ import urllib.parse
7
+ import urllib.request
8
+ from pathlib import Path
9
+ from typing import TypedDict
10
+
11
+ # MCServer
12
+ from . import __version__
13
+ from .shared import current_dir, format_number, mcserver_dir, print_status
14
+
15
+
16
+ # TypedDict
17
+ class ResponseObject(TypedDict):
18
+ text: str
19
+ headers: dict
20
+ status: int
21
+
22
+
23
+ # Errors
24
+ class DownloadURLError(Exception):
25
+ pass
26
+
27
+
28
+ # Variables
29
+ user_agent = f"Othecat35/MCServer/{__version__} (https://github.com/Othecat35/MCServer)"
30
+ read_chunk_size: int = 1024 * 64 # 64KiB
31
+
32
+ # Paths
33
+ tempfiles_dir = mcserver_dir / "tempfiles"
34
+
35
+
36
+ # Functions
37
+ def request(
38
+ url: str,
39
+ query: dict | None = None,
40
+ data: bytes | None = None,
41
+ headers: dict | None = None,
42
+ method: str = "GET",
43
+ timeout: int = 10,
44
+ ) -> ResponseObject:
45
+ method = method.upper()
46
+ if query is None:
47
+ query = {}
48
+ if headers is None:
49
+ headers = {}
50
+ headers.setdefault("User-Agent", user_agent)
51
+
52
+ query_string = f"?{urllib.parse.urlencode(query)}" if query else ""
53
+ request = urllib.request.Request(
54
+ f"{url}{query_string}", data=data, headers=headers, method=method
55
+ )
56
+
57
+ log.debug(f"Requesting URL: {method} {request.full_url}")
58
+ with urllib.request.urlopen(request, timeout=timeout) as response:
59
+ log.debug(f"Responded with status: {response.status} {response.reason}")
60
+
61
+ response_headers = {}
62
+ for key, value in response.getheaders():
63
+ response_headers[key.lower()] = value
64
+
65
+ return {
66
+ "text": response.read().decode("utf-8"),
67
+ "headers": response_headers,
68
+ "status": response.status,
69
+ }
70
+
71
+
72
+ def download(
73
+ url: str,
74
+ filename: str | Path,
75
+ hashes: dict | None = None,
76
+ headers: dict | None = None,
77
+ timeout: int = 10,
78
+ ):
79
+ filename = Path(filename)
80
+ if hashes is None:
81
+ hashes = {}
82
+ if headers is None:
83
+ headers = {}
84
+ headers.setdefault("User-Agent", user_agent)
85
+
86
+ basename = filename.name
87
+ tempfile = tempfiles_dir / basename
88
+
89
+ request_url = urllib.request.Request(url, headers=headers, method="GET")
90
+
91
+ log.debug(f"Downloading URL: {request_url.full_url}")
92
+ log.debug(f"Temporary file: {tempfile}")
93
+
94
+ hash_algorithm = None
95
+ hash_name = None
96
+
97
+ if hashes.get("sha512"):
98
+ hash_algorithm = hashlib.sha512()
99
+ hash_name = "sha512"
100
+ elif hashes.get("sha1"):
101
+ hash_algorithm = hashlib.sha1()
102
+ hash_name = "sha1"
103
+ elif hashes.get("md5"):
104
+ hash_algorithm = hashlib.md5()
105
+ hash_name = "md5"
106
+
107
+ expected_hash = None
108
+
109
+ if hash_name:
110
+ expected_hash = hashes[hash_name]
111
+ log.debug(f"Calculating hash {hash_name} while downloading...")
112
+ log.debug(f"Expected {hash_name}: {expected_hash}")
113
+
114
+ with urllib.request.urlopen(request_url, timeout=timeout) as response:
115
+ content_length = int(response.getheader("Content-Length", default=0))
116
+ downloaded_length = 0
117
+
118
+ with open(tempfile, mode="wb") as file:
119
+ while True:
120
+ data = response.read(read_chunk_size)
121
+
122
+ if not data:
123
+ break
124
+
125
+ if hash_algorithm:
126
+ hash_algorithm.update(data)
127
+
128
+ downloaded_length += len(data)
129
+
130
+ if content_length == 0:
131
+ print_status(
132
+ f"Downloading {basename}... (unknown final size)",
133
+ dynamic=f"Downloading {basename}... {format_number(downloaded_length, 'iec')}",
134
+ )
135
+ else:
136
+ print_status(
137
+ f"Downloading {basename}... File size: {format_number(content_length, 'iec')}",
138
+ dynamic=f"Downloading {basename}... {format_number(downloaded_length, 'iec')}/{format_number(content_length, 'iec')} ({round(downloaded_length / content_length * 100)}%)",
139
+ )
140
+
141
+ file.write(data)
142
+
143
+ if hash_algorithm:
144
+ calculated_hash = hash_algorithm.hexdigest()
145
+
146
+ log.debug(f"Got hash: {calculated_hash}")
147
+ if calculated_hash != expected_hash:
148
+ raise DownloadURLError(
149
+ f"Downloaded file '{tempfile.relative_to(current_dir)}' hash does not match with the expected {hash_name} hash"
150
+ )
151
+
152
+ shutil.move(tempfile, filename)
@@ -0,0 +1,81 @@
1
+ # Modules
2
+ # Standard
3
+ import json
4
+ from typing import Literal, TypedDict
5
+
6
+ # MCServer
7
+ from .. import networking
8
+ from ..constants import papermc_api_url
9
+
10
+
11
+ # TypedDicts
12
+ class FileHashes(TypedDict):
13
+ sha256: str # original: sha256
14
+
15
+
16
+ class BuildProp(TypedDict):
17
+ file_name: str # original: name
18
+ file_hashes: FileHashes # original: checksums
19
+ file_size: int # original: size
20
+ download_url: str # original: url
21
+
22
+
23
+ class BuildCommit(TypedDict):
24
+ commit_hash: str # original: hash
25
+ commit_time: str # original: time
26
+ commit_message: str # original: message
27
+
28
+
29
+ class ProjectBuild(TypedDict):
30
+ build_id: int # original: id
31
+ build_time: str # original: time
32
+ channel: str # original: channel
33
+ build_commits: list[BuildCommit] # original: commits
34
+ download_props: dict[str, BuildProp] # original: downloads
35
+
36
+
37
+ # Functions
38
+ def get_project_build(
39
+ project_name: str, game_version: str, build_id: int | Literal["latest"] = "latest"
40
+ ) -> ProjectBuild:
41
+ response = networking.request(
42
+ f"{papermc_api_url}/v3/projects/{project_name}/versions/{game_version}/builds/{build_id}"
43
+ )
44
+ response_json = json.loads(response["text"])
45
+
46
+ build_commits: list[BuildCommit] = []
47
+ for commit in response_json["commits"]:
48
+ build_commit: BuildCommit = {
49
+ "commit_hash": commit["sha"],
50
+ "commit_time": commit["time"],
51
+ "commit_message": commit["message"],
52
+ }
53
+
54
+ build_commits.append(build_commit)
55
+
56
+ download_props: dict[str, BuildProp] = {}
57
+ for prop_name, prop_data in response_json["downloads"].items():
58
+ download_prop: BuildProp = {
59
+ "file_name": prop_data["name"],
60
+ "file_hashes": prop_data["checksums"],
61
+ "file_size": prop_data["size"],
62
+ "download_url": prop_data["url"],
63
+ }
64
+
65
+ download_props[prop_name] = download_prop
66
+
67
+ # NOTE: probably use the "server:default" prop
68
+
69
+ project_build: ProjectBuild = {
70
+ "build_id": response_json["id"],
71
+ "build_time": response_json["time"],
72
+ "channel": response_json["channel"],
73
+ "build_commits": build_commits,
74
+ "download_props": download_props,
75
+ }
76
+
77
+ return project_build
78
+
79
+
80
+ def get_latest_project_build(project_name: str, game_version: str) -> ProjectBuild:
81
+ return get_project_build(project_name, game_version, "latest")
@@ -0,0 +1,81 @@
1
+ # Modules
2
+ # Standard
3
+ import json
4
+ from typing import Literal, TypedDict
5
+
6
+ # MCServer
7
+ from .. import networking
8
+ from ..constants import purpurmc_api_url
9
+
10
+
11
+ # TypedDicts
12
+ class BuildCommit(TypedDict):
13
+ author_name: str # original: author
14
+ author_email: str # original: email
15
+ commit_message: str # original: description
16
+ commit_hash: str # original: hash
17
+ commit_time: int # original: timestamp
18
+
19
+
20
+ class ProjectBuild(TypedDict):
21
+ project_name: str # original: project
22
+ game_version: str # original: version
23
+ build_id: int # original: build
24
+ build_status: str # original: result
25
+ build_time: int # original: timestamp
26
+ build_duration: int # original: duration
27
+ build_commits: list[BuildCommit] # original: commits
28
+ metadata: dict # original: metadata
29
+ artifact_md5: str # original: md5
30
+
31
+
32
+ # Functions
33
+ def get_project_build(
34
+ project_name: str, game_version: str, build_version: int | Literal["latest"]
35
+ ) -> ProjectBuild:
36
+ response = networking.request(
37
+ f"{purpurmc_api_url}/v2/{project_name}/{game_version}/{build_version}"
38
+ )
39
+ response_data = json.loads(response["text"])
40
+
41
+ build_commits: list[BuildCommit] = []
42
+ for commit in response_data["commits"]:
43
+ build_commit: BuildCommit = {
44
+ "author_name": commit["author"],
45
+ "author_email": commit["email"],
46
+ "commit_message": commit["description"],
47
+ "commit_hash": commit["hash"],
48
+ "commit_time": commit["timestamp"],
49
+ }
50
+
51
+ build_commits.append(build_commit)
52
+
53
+ project_build: ProjectBuild = {
54
+ "project_name": response_data["project"],
55
+ "game_version": response_data["version"],
56
+ "build_id": response_data["build"],
57
+ "build_status": response_data["result"],
58
+ "build_time": response_data["timestamp"],
59
+ "build_duration": response_data["duration"],
60
+ "build_commits": build_commits,
61
+ "metadata": response_data["metadata"],
62
+ "artifact_md5": response_data["md5"],
63
+ }
64
+
65
+ return project_build
66
+
67
+
68
+ def get_latest_project_build(project_name: str, game_version: str) -> ProjectBuild:
69
+ return get_project_build(project_name, game_version, "latest")
70
+
71
+
72
+ def download_url(
73
+ project_name: str, game_version: str, build_version: int | Literal["latest"]
74
+ ) -> str:
75
+ return (
76
+ f"{purpurmc_api_url}/v2/{project_name}/{game_version}/{build_version}/download"
77
+ )
78
+
79
+
80
+ def latest_download_url(project_name: str, game_version: str) -> str:
81
+ return download_url(project_name, game_version, "latest")
@@ -0,0 +1,54 @@
1
+ # Modules
2
+ # Standard
3
+ import json
4
+ from typing import TypedDict
5
+
6
+ # MCServer
7
+ from .. import networking
8
+ from ..constants import quiltmc_meta_url
9
+
10
+
11
+ # TypedDicts
12
+ class Hashes(TypedDict):
13
+ sha1: str
14
+ sha256: str
15
+ sha512: str
16
+
17
+
18
+ class LoaderVersion(TypedDict):
19
+ separator: str # this is the separator for the loader version
20
+ build_id: int
21
+ maven_id: str
22
+ loader_version: str
23
+ file_size: int
24
+ hashes: (
25
+ Hashes # what hash is this for? I don't know, please future me figure it out :>
26
+ )
27
+
28
+
29
+ # Functions
30
+ def get_loader_versions() -> list[LoaderVersion]:
31
+ response = networking.request(f"{quiltmc_meta_url}/v3/versions/loader")
32
+ response_json = json.loads(response["text"])
33
+
34
+ loader_versions: list[LoaderVersion] = []
35
+ for version in response_json:
36
+ version_hash = version["hashes"]
37
+ hashes: Hashes = {
38
+ "sha1": version_hash["sha1"],
39
+ "sha256": version_hash["sha256"],
40
+ "sha512": version_hash["sha512"],
41
+ }
42
+
43
+ loader_version: LoaderVersion = {
44
+ "separator": version["separator"],
45
+ "build_id": version["build"],
46
+ "maven_id": version["maven"],
47
+ "loader_version": version["version"],
48
+ "file_size": version["file_size"],
49
+ "hashes": hashes,
50
+ }
51
+
52
+ loader_versions.append(loader_version)
53
+
54
+ return loader_versions
mcserver/resolver.py ADDED
@@ -0,0 +1,164 @@
1
+ import logging as log
2
+ from collections import deque
3
+ from collections.abc import Callable
4
+ from typing import Literal, TypedDict, TypeAlias
5
+
6
+ DependencyType: TypeAlias = Literal["embedded", "optional", "required", "incompatible"]
7
+ dependency_types: dict[DependencyType, int] = {
8
+ "embedded": 0,
9
+ "optional": 1,
10
+ "required": 2,
11
+ "incompatible": 3,
12
+ }
13
+ dependency_types2: list[DependencyType] = [
14
+ "embedded",
15
+ "optional",
16
+ "required",
17
+ "incompatible",
18
+ ]
19
+
20
+
21
+ class Dependency(TypedDict):
22
+ project_id: str
23
+ dependency_type: DependencyType
24
+
25
+
26
+ class Project(TypedDict):
27
+ is_manual: bool
28
+ dependencies: dict[str, int]
29
+ dependents: dict[str, int]
30
+
31
+
32
+ class ProjectData(TypedDict):
33
+ project_id: str
34
+ is_manual: bool
35
+ dependencies: list[Dependency]
36
+ dependents: list[Dependency]
37
+
38
+
39
+ # NOTE: These are just theory, or not?
40
+ def human_to_resolver(dependencies: list[Dependency]) -> dict[str, int]:
41
+ resolver_dependencies: dict[str, int] = {}
42
+ for dependency in dependencies:
43
+ resolver_dependencies[dependency["project_id"]] = dependency_types[
44
+ dependency["dependency_type"]
45
+ ]
46
+
47
+ return resolver_dependencies
48
+
49
+
50
+ def resolver_to_human(dependency_graph: dict[str, Project]) -> list[ProjectData]:
51
+ projects: list[ProjectData] = []
52
+ for project_id, project_data in dependency_graph.items():
53
+ dependencies: list[Dependency] = []
54
+ for dependency_id, dependency_type in project_data["dependencies"].items():
55
+ dependency: Dependency = {
56
+ "project_id": dependency_id,
57
+ "dependency_type": dependency_types2[dependency_type],
58
+ }
59
+
60
+ dependencies.append(dependency)
61
+
62
+ dependents: list[Dependency] = []
63
+ for dependency_id, dependency_type in project_data["dependents"].items():
64
+ dependent: Dependency = {
65
+ "project_id": dependency_id,
66
+ "dependency_type": dependency_types2[dependency_type],
67
+ }
68
+
69
+ dependents.append(dependent)
70
+
71
+ project: ProjectData = {
72
+ "project_id": project_id,
73
+ "is_manual": project_data["is_manual"],
74
+ "dependencies": dependencies,
75
+ "dependents": dependents,
76
+ }
77
+
78
+ projects.append(project)
79
+
80
+ return projects
81
+
82
+
83
+ def resolve_dependencies(
84
+ project_ids: list[str] | str,
85
+ get_dependencies: Callable[[str], dict[str, int]],
86
+ should_queue: Callable[[int], bool],
87
+ ) -> dict[str, Project]:
88
+ """Resolve dependency tree using BFS
89
+
90
+ Args:
91
+ project_ids: Projects to start with
92
+ get_dependencies: Callback that returns a dictionary
93
+ should_queue: Callback that returns a bool
94
+
95
+ Raises:
96
+ TypeError: Callback 'get_dependency' does not return a dictionary
97
+ """
98
+ if isinstance(project_ids, str):
99
+ project_ids = [project_ids]
100
+
101
+ queued_projects: deque[str] = deque()
102
+ visited_projects: set[str] = set()
103
+ dependency_graph: dict[str, Project] = {}
104
+
105
+ for project_id in project_ids:
106
+ log.debug(f"Adding project '{project_id}' to the queue")
107
+ queued_projects.append(project_id)
108
+ project: Project = {"is_manual": True, "dependencies": {}, "dependents": {}}
109
+ dependency_graph[project_id] = project
110
+
111
+ while queued_projects:
112
+ project_id = queued_projects.popleft()
113
+ if project_id in visited_projects:
114
+ log.debug(f"Project '{project_id}' has already been visited")
115
+ continue
116
+
117
+ dependencies = get_dependencies(project_id)
118
+ dependency_graph[project_id]["dependencies"] = dependencies
119
+
120
+ for dependency_id, dependency_type in dependencies.items():
121
+ if dependency_id in visited_projects:
122
+ dependency_graph[dependency_id]["dependents"][
123
+ project_id
124
+ ] = dependency_type
125
+ else:
126
+ dependency_graph[dependency_id] = {
127
+ "is_manual": False,
128
+ "dependencies": {},
129
+ "dependents": {project_id: dependency_type},
130
+ }
131
+
132
+ if should_queue(dependency_type):
133
+ log.debug(f"Adding dependency '{dependency_id}' to queue")
134
+ queued_projects.append(dependency_id)
135
+
136
+ visited_projects.add(project_id)
137
+
138
+ return dependency_graph
139
+
140
+
141
+ def required_only(dependency_type: int) -> bool:
142
+ return dependency_type == dependency_types["required"]
143
+
144
+
145
+ def test_dependencies(project_id: str) -> dict[str, int]:
146
+ dependencies = {
147
+ "embeddium": [{"project_id": "sodium", "dependency_type": "incompatible"}],
148
+ "fabric-api": [],
149
+ "origins": [{"project_id": "fabric-api", "dependency_type": "required"}],
150
+ "pehkui": [{"project_id": "fabric-api", "dependency_type": "required"}],
151
+ "podium": [{"project_id": "sodium", "dependency_type": "required"}],
152
+ "sodium": [],
153
+ "thdilos-fox-origin-expanded": [
154
+ {"project_id": "thdilos-fox-origin", "dependency_type": "required"},
155
+ {"project_id": "pehkui", "dependency_type": "required"},
156
+ {"project_id": "origins", "dependency_type": "required"},
157
+ ],
158
+ "thdilos-fox-origin": [
159
+ {"project_id": "pehkui", "dependency_type": "required"},
160
+ {"project_id": "origins", "dependency_type": "required"},
161
+ ]
162
+ }
163
+
164
+ return human_to_resolver(dependencies[project_id])
@@ -0,0 +1,120 @@
1
+ # Modules
2
+ # Standard
3
+ import json
4
+ import logging as log
5
+ from pathlib import Path
6
+ from typing import Literal, TypedDict
7
+
8
+ # Paths
9
+ banned_ips_file = Path("banned-ips.json")
10
+
11
+
12
+ # TypedDicts
13
+ class BannedIPEntry(TypedDict):
14
+ ip: str | Literal["<unknown>"] # IPv4 or IPv6
15
+ created: str # Time format: yyyy-MM-dd HH:mm:ss Z
16
+ source: str
17
+ expires: Literal["forever"] | str # Time Format: yyyy-MM-dd HH:mm:ss Z
18
+ reason: str
19
+
20
+
21
+ class BannedIPRecord(TypedDict):
22
+ ip_address: str # IPv4 or IPv6
23
+ created_time: str # Time format: yyyy-MM-dd HH:mm:ss Z
24
+ ban_source: str
25
+ expires_time: str | Literal["forever"] # Time format: yyyy-MM-dd HH:mm:ss Z
26
+ ban_reason: str
27
+
28
+
29
+ # Functions
30
+ # Add IPs
31
+ def add_ip(
32
+ ip_address: str,
33
+ created_time: str,
34
+ ban_source: str,
35
+ expires_time: str | Literal["forever"] = "forever",
36
+ ban_reason: str = "Banned via MCServer.",
37
+ ) -> None:
38
+ banned_ip_record: BannedIPRecord = {
39
+ "ip_address": ip_address,
40
+ "created_time": created_time,
41
+ "ban_source": ban_source,
42
+ "expires_time": expires_time,
43
+ "ban_reason": ban_reason,
44
+ }
45
+
46
+ add_ips(banned_ip_record)
47
+
48
+
49
+ def add_ips(banned_ip_records: list[BannedIPRecord] | BannedIPRecord) -> None:
50
+ if not isinstance(banned_ip_records, list):
51
+ banned_ip_records = [banned_ip_records]
52
+
53
+ with open(banned_ips_file, mode="r+") as file:
54
+ banned_ips_data = json.load(file)
55
+ for ip_record in banned_ip_records:
56
+ banned_ip_entry: BannedIPEntry = {
57
+ "ip": ip_record["ip_address"],
58
+ "created": ip_record["created_time"],
59
+ "source": ip_record["ban_source"],
60
+ "expires": ip_record["expires_time"],
61
+ "reason": ip_record["ban_reason"],
62
+ }
63
+
64
+ banned_ips_data.append(banned_ip_entry)
65
+
66
+ file.seek(0)
67
+ json.dump(banned_ips_data, file, indent=2)
68
+ file.truncate()
69
+
70
+
71
+ # Remove IPs
72
+ def remove_ip(ip_address: str) -> None:
73
+ remove_ips(ip_address)
74
+
75
+
76
+ def remove_ips(ip_addresses: list[str] | str) -> None:
77
+ if not isinstance(ip_addresses, list):
78
+ ip_addresses = [ip_addresses]
79
+
80
+ unbanned_ip_addresses: set[str] = set()
81
+ for ip_address in ip_addresses:
82
+ unbanned_ip_addresses.add(ip_address)
83
+
84
+ with open(banned_ips_file, mode="r+") as file:
85
+ banned_ips_data: list[BannedIPEntry] = json.load(file)
86
+ new_banned_ips_data: list[BannedIPEntry] = []
87
+ for ip_entry in banned_ips_data:
88
+ ip_address = ip_entry["ip"]
89
+ if ip_address in unbanned_ip_addresses:
90
+ log.debug(
91
+ f"Removed IP address '{ip_address}' from the banned IP address list"
92
+ )
93
+ continue
94
+
95
+ new_banned_ips_data.append(ip_entry)
96
+
97
+ file.seek(0)
98
+ json.dump(new_banned_ips_data, file, indent=2)
99
+ file.truncate()
100
+
101
+
102
+ # TODO: add 'update_ip' and 'update_ips', just use plain dict.update() because it has no depth
103
+
104
+
105
+ # List IPs
106
+ def list_ips() -> list[BannedIPRecord]:
107
+ banned_ip_data: list[BannedIPEntry] = json.loads(banned_ips_file.read_text())
108
+ banned_ip_records: list[BannedIPRecord] = []
109
+ for ip_entry in banned_ip_data:
110
+ banned_ip_record: BannedIPRecord = {
111
+ "ip_address": ip_entry["ip"],
112
+ "created_time": ip_entry["created"],
113
+ "ban_source": ip_entry["source"],
114
+ "expires_time": ip_entry["expires"],
115
+ "ban_reason": ip_entry["reason"],
116
+ }
117
+
118
+ banned_ip_records.append(banned_ip_record)
119
+
120
+ return banned_ip_records