hfsapi 0.1.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.
hfsapi/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """HFS (HTTP File Server) Python API 客户端 - https://github.com/rejetto/hfs"""
2
+
3
+ from hfsapi.client import HFSClient
4
+ from hfsapi.models import (
5
+ DirEntry,
6
+ FileListResponse,
7
+ entry_created,
8
+ entry_modified,
9
+ entry_permissions,
10
+ entry_size,
11
+ )
12
+
13
+ __all__ = [
14
+ "HFSClient",
15
+ "DirEntry",
16
+ "FileListResponse",
17
+ "entry_size",
18
+ "entry_created",
19
+ "entry_modified",
20
+ "entry_permissions",
21
+ ]
hfsapi/client.py ADDED
@@ -0,0 +1,324 @@
1
+ """
2
+ HFS (HTTP File Server) Python API 客户端。
3
+
4
+ 基于 https://github.com/rejetto/hfs 的 OpenAPI 与前端行为实现,
5
+ 支持登录、文件列表、上传、配置及权限相关操作。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ from typing import Any, BinaryIO
12
+ from urllib.parse import urlencode
13
+
14
+ import httpx
15
+
16
+ from hfsapi.models import DirEntry, FileListResponse
17
+
18
+ # POST 请求需携带的防 CSRF 头(HFS OpenAPI 要求)
19
+ HFS_ANTI_CSRF_HEADER = "x-hfs-anti-csrf"
20
+ HFS_ANTI_CSRF_VALUE = "1"
21
+
22
+
23
+ class HFSClient:
24
+ """
25
+ HFS 服务器 API 客户端。
26
+
27
+ 认证方式:使用 Basic HTTP 认证或首次请求带 ?login=用户名:密码 建立会话。
28
+ 测试示例: base_url="http://127.0.0.1:8280", username="abct", password="abc123"
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ base_url: str,
34
+ username: str | None = None,
35
+ password: str | None = None,
36
+ *,
37
+ timeout: float = 30.0,
38
+ verify: bool = True,
39
+ ):
40
+ """
41
+ :param base_url: 服务器根地址,如 http://127.0.0.1:8280(不要带末尾 /data/)
42
+ :param username: 登录用户名,如 abct
43
+ :param password: 登录密码,如 abc123
44
+ :param timeout: 请求超时秒数
45
+ :param verify: 是否验证 HTTPS 证书
46
+ """
47
+ self.base_url = base_url.rstrip("/")
48
+ self.username = username
49
+ self.password = password
50
+ self.timeout = timeout
51
+ self.verify = verify
52
+ self._api_base = f"{self.base_url}/~/api"
53
+ self._client: httpx.Client | None = None
54
+
55
+ def _get_client(self) -> httpx.Client:
56
+ if self._client is None or self._client.is_closed:
57
+ auth = None
58
+ if self.username is not None and self.password is not None:
59
+ auth = (self.username, self.password)
60
+ self._client = httpx.Client(
61
+ base_url=self.base_url,
62
+ auth=auth,
63
+ timeout=self.timeout,
64
+ verify=self.verify,
65
+ follow_redirects=True,
66
+ )
67
+ return self._client
68
+
69
+ def _post_headers(self) -> dict[str, str]:
70
+ return {HFS_ANTI_CSRF_HEADER: HFS_ANTI_CSRF_VALUE}
71
+
72
+ def close(self) -> None:
73
+ """关闭底层 HTTP 客户端。"""
74
+ if self._client and not self._client.is_closed:
75
+ self._client.close()
76
+ self._client = None
77
+
78
+ def __enter__(self) -> HFSClient:
79
+ return self
80
+
81
+ def __exit__(self, *args: Any) -> None:
82
+ self.close()
83
+
84
+ # ------------------------- 登录与会话 -------------------------
85
+
86
+ def login(self) -> bool:
87
+ """
88
+ 使用 URL 参数方式建立登录会话(可选)。
89
+ 若已使用 Basic 认证构造客户端,则无需单独调用。
90
+ 返回是否请求成功(不保证服务端一定接受凭证)。
91
+ """
92
+ if self.username is None or self.password is None:
93
+ return False
94
+ url = f"{self.base_url}/?login={self.username}:{self.password}"
95
+ try:
96
+ r = self._get_client().get(url)
97
+ return r.is_success
98
+ except Exception:
99
+ return False
100
+
101
+ # ------------------------- 文件列表(对应图片中的「谁可以访问列表」等) -------------------------
102
+
103
+ def get_file_list(
104
+ self,
105
+ uri: str = "/",
106
+ *,
107
+ offset: int | None = None,
108
+ limit: int | None = None,
109
+ search: str | None = None,
110
+ request_c_and_m: bool = False,
111
+ ) -> FileListResponse:
112
+ """
113
+ 获取指定目录下的文件/文件夹列表(含权限与元数据)。
114
+
115
+ 对应前端「谁可以访问列表」:有权限时才能拿到 list;列表中每项包含:
116
+ - 名称、创建/修改时间、大小
117
+ - 权限缩写 p(r/R/l/L/d 等,仅在与父级不同时返回)
118
+
119
+ :param uri: 目录路径,如 "/" 或 "/data"
120
+ :param offset: 跳过条数
121
+ :param limit: 最多返回条数
122
+ :param search: 搜索关键词(含子目录)
123
+ :param request_c_and_m: 是否同时请求 c(创建)和 m(修改)时间
124
+ :return: 含 can_archive, can_upload, can_delete, can_comment, list 等字段
125
+ """
126
+ params: dict[str, Any] = {"uri": uri}
127
+ if offset is not None:
128
+ params["offset"] = offset
129
+ if limit is not None:
130
+ params["limit"] = limit
131
+ if search:
132
+ params["search"] = search
133
+ if request_c_and_m:
134
+ params["c"] = "1"
135
+ r = self._get_client().get(f"{self._api_base}/get_file_list", params=params)
136
+ r.raise_for_status()
137
+ return r.json()
138
+
139
+ def list_entries(self, uri: str = "/", **kwargs: Any) -> list[DirEntry]:
140
+ """便捷方法:只返回 get_file_list 的 list 数组。"""
141
+ data = self.get_file_list(uri, **kwargs)
142
+ return data.get("list", [])
143
+
144
+ # ------------------------- 下载(对应「谁可以下载」) -------------------------
145
+
146
+ def download_file(
147
+ self,
148
+ path: str,
149
+ save_to: str | None = None,
150
+ ) -> bytes:
151
+ """
152
+ 下载文件。GET 指定路径,返回内容;若提供 save_to 则写入该路径。
153
+
154
+ :param path: 远程路径,如 "/data/Welcome.md" 或 "share/foo.txt"
155
+ :param save_to: 本地保存路径,若提供则写入文件
156
+ :return: 文件内容(bytes)
157
+ """
158
+ path = path.strip("/")
159
+ # 使用相对路径,避免带 base_url 的 client 将完整 URL 再拼一次
160
+ url = f"/{path}" if path else "/"
161
+ r = self._get_client().get(url)
162
+ r.raise_for_status()
163
+ content = r.content
164
+ if save_to:
165
+ from pathlib import Path
166
+
167
+ Path(save_to).parent.mkdir(parents=True, exist_ok=True)
168
+ Path(save_to).write_bytes(content)
169
+ return content
170
+
171
+ # ------------------------- 上传(对应「谁可以上传」) -------------------------
172
+
173
+ def upload_file(
174
+ self,
175
+ folder: str,
176
+ file_content: BinaryIO | bytes,
177
+ filename: str | None = None,
178
+ *,
179
+ use_put: bool = False,
180
+ put_params: dict[str, str] | None = None,
181
+ use_session_for_put: bool = False,
182
+ ) -> httpx.Response:
183
+ """
184
+ 上传文件到指定目录。
185
+
186
+ :param folder: 目录路径,如 "" 或 "share" 或 "share/sub"
187
+ :param file_content: 文件内容(文件对象或 bytes)
188
+ :param filename: 使用 PUT 时的文件名;POST 时由服务端从 multipart 解析
189
+ :param use_put: True 时用 PUT /{folder}/{filename},否则用 POST /{folder} multipart
190
+ :param put_params: PUT 时附加的 query 参数(与前端一致时可传 resume=0! 等)
191
+ :param use_session_for_put: True 时用仅 session(无 Basic)发 PUT,与浏览器一致,需先 login()
192
+ :return: 响应对象,可检查 .status_code 与 .json()
193
+ """
194
+ folder = folder.strip("/")
195
+ if use_put:
196
+ if not filename:
197
+ raise ValueError("use_put=True 时必须提供 filename")
198
+ # 使用相对路径,避免带 base_url 的 client 将 URL 重复拼接(与 download_file 一致)
199
+ path = f"{folder}/{filename}".replace("//", "/") if folder else filename
200
+ url = f"/{path}" if path else "/"
201
+ if isinstance(file_content, bytes):
202
+ body = file_content
203
+ else:
204
+ body = file_content.read()
205
+ if put_params:
206
+ url = f"{url}?{urlencode(put_params)}"
207
+ # 与 HFS 前端一致:Referer 为当前目录 URL
208
+ referer = f"{self.base_url}/{folder}/".replace("//", "/") if folder else f"{self.base_url}/"
209
+ headers = {**self._post_headers(), "Referer": referer}
210
+ if use_session_for_put and self.username and self.password:
211
+ session_client = httpx.Client(
212
+ base_url=self.base_url,
213
+ timeout=self.timeout,
214
+ verify=self.verify,
215
+ follow_redirects=True,
216
+ )
217
+ try:
218
+ session_client.get(f"/?login={self.username}:{self.password}")
219
+ if folder:
220
+ session_client.get(f"/{folder}/")
221
+ r = session_client.put(url, content=body, headers=headers)
222
+ # HFS roots:若 host 映射到 root(如 /data),需发 PUT /filename 相对 root
223
+ if r.status_code == 404 and folder:
224
+ url_rel = f"/{filename}?{urlencode(put_params)}" if put_params else f"/{filename}"
225
+ r = session_client.put(url_rel, content=body, headers=headers)
226
+ return r
227
+ finally:
228
+ session_client.close()
229
+ return self._get_client().put(url, content=body, headers=headers)
230
+ # POST multipart:HFS 文档写的是 curl -F upload=@FILE FOLDER/,字段名用 upload
231
+ url = f"{self.base_url}/{folder}".replace("//", "/") if folder else self.base_url
232
+ if isinstance(file_content, bytes):
233
+ file_content = io.BytesIO(file_content)
234
+ files = {"upload": (filename or "file", file_content, "application/octet-stream")}
235
+ return self._get_client().post(
236
+ url,
237
+ files=files,
238
+ headers=self._post_headers(),
239
+ )
240
+
241
+ # ------------------------- 删除(对应「谁可以删除」) -------------------------
242
+
243
+ def delete_file(self, folder: str, filename: str) -> httpx.Response:
244
+ """
245
+ 删除指定目录下的文件。
246
+
247
+ HFS 3:对文件路径发 DELETE 请求(path 即要删除的文件);需当前用户有 can_delete 权限。
248
+
249
+ :param folder: 目录路径,如 "share" 或 "share/sub"
250
+ :param filename: 要删除的文件名(仅文件名,不含路径)
251
+ :return: 响应对象,可检查 .status_code
252
+ """
253
+ folder = folder.strip("/")
254
+ path = f"{folder}/{filename}".replace("//", "/") if folder else filename
255
+ url = f"/{path}" if path else "/"
256
+ return self._get_client().delete(url)
257
+
258
+ # ------------------------- 配置(权限、VFS、Serve as web-page 等) -------------------------
259
+
260
+ def get_config(
261
+ self,
262
+ only: list[str] | None = None,
263
+ omit: list[str] | None = None,
264
+ ) -> dict[str, Any]:
265
+ """
266
+ 获取服务器配置(含 VFS、权限等)。
267
+
268
+ :param only: 只返回这些键
269
+ :param omit: 返回除这些键外的所有键
270
+ """
271
+ params: dict[str, Any] = {}
272
+ if only is not None:
273
+ params["only"] = only
274
+ if omit is not None:
275
+ params["omit"] = omit
276
+ r = self._get_client().get(f"{self._api_base}/get_config", params=params)
277
+ r.raise_for_status()
278
+ return r.json()
279
+
280
+ def set_config(self, values: dict[str, Any]) -> httpx.Response:
281
+ """
282
+ 设置配置项。可用于修改 VFS 节点权限(can_read, can_see, can_upload 等)
283
+ 以及「如果找到 index.html 则作为网页提供」等选项(对应 default 等)。
284
+
285
+ 与界面权限对应关系:
286
+ - can_read: 谁可以下载
287
+ - can_archive: 谁可以压缩
288
+ - can_list: 谁可以访问列表
289
+ - can_delete: 谁可以删除
290
+ - can_upload: 谁可以上传
291
+ - can_see: 谁可以查看
292
+ - default: 如 "index.html" 表示该文件夹「作为网页提供」
293
+
294
+ :param values: 配置键值,参见 HFS config.md
295
+ """
296
+ return self._get_client().post(
297
+ f"{self._api_base}/set_config",
298
+ json={"values": values},
299
+ headers=self._post_headers(),
300
+ )
301
+
302
+ def get_vfs(self) -> Any:
303
+ """获取当前 VFS 配置(文件/文件夹树及权限)。"""
304
+ return self.get_config(only=["vfs"]).get("vfs", [])
305
+
306
+ # ------------------------- 账户(可选,管理员接口) -------------------------
307
+
308
+ def get_accounts(self) -> list[dict[str, Any]]:
309
+ """获取账户列表(需管理员权限)。"""
310
+ r = self._get_client().get(f"{self._api_base}/get_accounts")
311
+ r.raise_for_status()
312
+ return r.json().get("list", [])
313
+
314
+ def get_usernames(self) -> list[str]:
315
+ """获取用户名列表。"""
316
+ r = self._get_client().get(f"{self._api_base}/get_usernames")
317
+ r.raise_for_status()
318
+ return r.json().get("list", [])
319
+
320
+ def get_account(self, username: str) -> dict[str, Any]:
321
+ """获取单个账户信息。"""
322
+ r = self._get_client().get(f"{self._api_base}/get_account", params={"username": username})
323
+ r.raise_for_status()
324
+ return r.json()
hfsapi/models.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ HFS API 数据模型(与 OpenAPI / 前端一致)。
3
+
4
+ 与界面「谁可以下载/压缩/访问列表/删除/上传/查看」对应:
5
+ - 列表项中的 p:权限缩写,仅在与父级不同时返回。
6
+ - r: 不可下载 R: 仅其他凭证可下载
7
+ - l: 不可列目录 L: 仅其他凭证可列目录
8
+ - d: 可删除
9
+ - 修改这些权限需通过 set_config 修改 VFS 节点上的 can_read / can_archive / can_list / can_delete / can_upload / can_see。
10
+ """
11
+
12
+ from typing import Any
13
+
14
+ # DirEntry:get_file_list 返回的 list 中每一项
15
+ # n=名称, c=创建时间, m=修改时间, s=大小(字节), p=权限缩写, comment=注释
16
+ DirEntry = dict[str, Any]
17
+
18
+ # get_file_list 响应:can_archive, can_upload, can_delete, can_comment, list
19
+ FileListResponse = dict[str, Any]
20
+
21
+
22
+ def entry_size(entry: DirEntry) -> int:
23
+ """条目大小(字节),文件夹可为 0 或未提供。"""
24
+ return int(entry.get("s") or 0)
25
+
26
+
27
+ def entry_created(entry: DirEntry) -> str | None:
28
+ """条目创建时间(ISO 字符串)。"""
29
+ return entry.get("c")
30
+
31
+
32
+ def entry_modified(entry: DirEntry) -> str | None:
33
+ """条目修改时间(ISO 字符串)。"""
34
+ return entry.get("m")
35
+
36
+
37
+ def entry_permissions(entry: DirEntry) -> str:
38
+ """权限字符串,如 'rLd';仅在与父级不同时存在。"""
39
+ return entry.get("p") or ""
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: hfsapi
3
+ Version: 0.1.0
4
+ Summary: HFS (HTTP File Server) Python API 客户端
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.27.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
10
+
11
+ # hfsapi
12
+
13
+ [HFS (HTTP File Server)](https://github.com/rejetto/hfs) 的 Python API 客户端,支持登录、文件列表、上传、配置与权限相关操作。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ uv sync
19
+ # 或
20
+ pip install -e .
21
+ ```
22
+
23
+ ## 快速开始
24
+
25
+ ```python
26
+ from hfsapi import HFSClient, entry_size, entry_created, entry_modified
27
+
28
+ with HFSClient("http://127.0.0.1:8280", username="abct", password="abc123") as client:
29
+ data = client.get_file_list(uri="/data")
30
+ for e in data.get("list", []):
31
+ print(e["n"], entry_size(e), entry_created(e), entry_modified(e))
32
+
33
+ # 上传
34
+ client.upload_file("data", b"content", filename="hello.txt", use_put=True)
35
+ ```
36
+
37
+ ## 核心 API
38
+
39
+ | 方法 / 函数 | 说明 |
40
+ |-------------|------|
41
+ | **HFSClient**(base_url, username, password, timeout) | 客户端;建议用 `with` 或显式 `close()`。 |
42
+ | **login()** | 使用 URL 参数建立会话(与 Basic 二选一或配合使用)。 |
43
+ | **get_file_list**(uri, offset, limit, search, request_c_and_m) | 获取目录列表及当前用户在该目录的权限、条目元数据。 |
44
+ | **list_entries**(uri, ...) | 仅返回 `get_file_list` 的 `list` 数组。 |
45
+ | **upload_file**(folder, file_content, filename, use_put, put_params, use_session_for_put) | 上传文件到指定目录。 |
46
+ | **delete_file**(folder, filename) | 删除指定目录下的文件。 |
47
+ | **get_config**(only, omit) / **set_config**(values) | 读取/写入 HFS 配置;可改 VFS 与权限等。 |
48
+ | **get_vfs()** | 获取当前 VFS 树(含权限结构)。 |
49
+ | **entry_size**(e) / **entry_created**(e) / **entry_modified**(e) / **entry_permissions**(e) | 列表项元数据与权限缩写解析。 |
50
+
51
+ 列表响应中:`n` 名称、`s` 大小、`c`/`m` 创建/修改时间;`can_archive`、`can_upload`、`can_delete` 等表示当前用户在该目录的权限。
52
+
53
+ 更多说明(权限对应、测试、发布、上传方式与 roots 等)见 **[HELP.md](HELP.md)**。
@@ -0,0 +1,7 @@
1
+ hfsapi/__init__.py,sha256=hTZfQrPh9TzCAcXt9BBUNntzL2JPauKnadUVhnDkEns,422
2
+ hfsapi/client.py,sha256=rpgVGnI35azOY0r4s9hqWdrLFrTWEkrGRuhzD3MCApI,12799
3
+ hfsapi/models.py,sha256=DFGng0mHc0UyUtEwpq5XQX8zL8sTE5qZMVBE3PQi7dM,1353
4
+ hfsapi-0.1.0.dist-info/METADATA,sha256=Bd3g0lbIEjEr-2eIJ98-OK9WIC5jSrAt4bv0fcsDLAE,2206
5
+ hfsapi-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
+ hfsapi-0.1.0.dist-info/top_level.txt,sha256=W7sHdemBjJ6XhEileCvntDqiAa9kNqV8Lt0drPKDMtk,7
7
+ hfsapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ hfsapi