kcfinder-client 0.2.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.
@@ -0,0 +1,42 @@
1
+ """Python client library for KCFinder file manager."""
2
+
3
+ from kcfinder_client.async_client import AsyncKCFinderClient
4
+ from kcfinder_client.auth import (
5
+ BaseAuth,
6
+ HarmonySiteAuth,
7
+ SessionAuth,
8
+ harmonysite_auth_from_env,
9
+ )
10
+ from kcfinder_client.client import KCFinderClient
11
+ from kcfinder_client.exceptions import (
12
+ ActionError,
13
+ AuthError,
14
+ DirectoryOperationError,
15
+ FileOperationError,
16
+ KCFinderError,
17
+ PermissionDeniedError,
18
+ UploadError,
19
+ )
20
+ from kcfinder_client.models import DirTree, FileInfo, SyncResult
21
+ from kcfinder_client.sync import SyncManager, SyncManagerSync
22
+
23
+ __all__ = [
24
+ "ActionError",
25
+ "AsyncKCFinderClient",
26
+ "AuthError",
27
+ "BaseAuth",
28
+ "DirTree",
29
+ "DirectoryOperationError",
30
+ "FileInfo",
31
+ "FileOperationError",
32
+ "HarmonySiteAuth",
33
+ "KCFinderClient",
34
+ "KCFinderError",
35
+ "PermissionDeniedError",
36
+ "SessionAuth",
37
+ "SyncManager",
38
+ "SyncManagerSync",
39
+ "SyncResult",
40
+ "UploadError",
41
+ "harmonysite_auth_from_env",
42
+ ]
@@ -0,0 +1,177 @@
1
+ """Shared request-building and response-parsing logic for KCFinder clients."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from urllib.parse import urlencode
6
+
7
+ from kcfinder_client.exceptions import classify_error
8
+ from kcfinder_client.models import DirTree, FileInfo
9
+
10
+
11
+ def prefix_dir(file_type: str, dir: str) -> str:
12
+ """Build a type-prefixed directory path for KCFinder.
13
+
14
+ KCFinder expects all ``dir`` parameters to start with the file type
15
+ (e.g., ``images/subfolder``). The PHP constructor validates this prefix
16
+ via ``checkInputDir`` and strips it before resolving the filesystem path.
17
+ """
18
+ if dir:
19
+ return f"{file_type}/{dir}"
20
+ return file_type
21
+
22
+
23
+ def prefix_file_paths(file_type: str, paths: list[str]) -> list[str]:
24
+ """Prepend the file type to each path for bulk clipboard operations.
25
+
26
+ Bulk actions (cp_cbd, mv_cbd, rm_cbd) expect each file entry to be a
27
+ type-prefixed path like ``images/subfolder/photo.jpg``.
28
+ """
29
+ return [f"{file_type}/{p}" for p in paths]
30
+
31
+
32
+ def build_action_url(
33
+ browse_url: str,
34
+ action: str,
35
+ file_type: str | None,
36
+ extra_params: dict[str, str] | None = None,
37
+ ) -> str:
38
+ """Build the full URL for a KCFinder action."""
39
+ params: dict[str, str] = {}
40
+ if extra_params:
41
+ params.update(extra_params)
42
+ params["act"] = action
43
+ if file_type is not None:
44
+ params["type"] = file_type
45
+ return f"{browse_url}?{urlencode(params)}"
46
+
47
+
48
+ def build_headers(referer: str) -> dict[str, str]:
49
+ """Build the required headers for a KCFinder request."""
50
+ return {
51
+ "X-Requested-With": "XMLHttpRequest",
52
+ "Referer": referer,
53
+ }
54
+
55
+
56
+ def build_form_data(
57
+ *,
58
+ dir: str | None = None,
59
+ file: str | None = None,
60
+ new_name: str | None = None,
61
+ new_dir: str | None = None,
62
+ files: list[str] | None = None,
63
+ ) -> dict[str, str | list[str]]:
64
+ """Build the form data dict for a KCFinder action."""
65
+ data: dict[str, str | list[str]] = {}
66
+ if dir is not None:
67
+ data["dir"] = dir
68
+ if file is not None:
69
+ data["file"] = file
70
+ if new_name is not None:
71
+ data["newName"] = new_name
72
+ if new_dir is not None:
73
+ data["newDir"] = new_dir
74
+ if files is not None:
75
+ data["files[]"] = files
76
+ return data
77
+
78
+
79
+ def parse_file_list(raw: dict) -> list[FileInfo]:
80
+ """Parse a file list from a KCFinder response into FileInfo objects."""
81
+ writable = raw.get("dirWritable", False)
82
+ return [
83
+ FileInfo(
84
+ name=f["name"],
85
+ size=f["size"],
86
+ mtime=datetime.fromtimestamp(f["mtime"], tz=timezone.utc), # noqa: UP017
87
+ is_writable=f.get("writable", writable),
88
+ )
89
+ for f in raw.get("files", [])
90
+ ]
91
+
92
+
93
+ def parse_dir_tree(raw: dict) -> DirTree:
94
+ """Parse a KCFinder init response into a DirTree.
95
+
96
+ The init response wraps the tree in a "tree" key at the top level.
97
+ Files are siblings of "tree", not nested under it.
98
+ """
99
+ tree = raw.get("tree", raw)
100
+ files = parse_file_list(raw) if "files" in raw else []
101
+ return _parse_tree_node(tree, files)
102
+
103
+
104
+ def _parse_tree_node(node: dict, files: list[FileInfo] | None = None) -> DirTree:
105
+ """Parse a single tree node recursively."""
106
+ return DirTree(
107
+ name=node["name"],
108
+ is_writable=node.get("writable", False),
109
+ has_subdirs=node.get("hasDirs", False),
110
+ children=[_parse_tree_node(child) for child in node.get("dirs", [])],
111
+ files=files if files is not None else [],
112
+ )
113
+
114
+
115
+ def check_upload_response(response_text: str) -> None:
116
+ """Check an upload response for errors.
117
+
118
+ Upload responses differ from other actions: success returns the
119
+ uploaded filename prefixed with "/" (e.g., "/photo.jpg"). Errors
120
+ return "filename: error message". Multiple files produce one line
121
+ per file.
122
+ """
123
+ for line in response_text.strip().splitlines():
124
+ line = line.strip()
125
+ if not line or line.startswith("/"):
126
+ continue # success — uploaded filename
127
+ raise classify_error(action="upload", message=line)
128
+
129
+
130
+ def check_action_error(action: str, response_body: str | dict) -> None:
131
+ """Check a KCFinder response body for errors and raise if found.
132
+
133
+ KCFinder returns HTTP 200 even on errors. Successful mutating actions
134
+ return the string ``{}`` (PHP echoes ``{}`` when the handler returns
135
+ ``true``). Query actions return valid JSON. Errors come as plain
136
+ strings or ``{"error": "message"}`` dicts. Bulk clipboard actions
137
+ return ``{"error": ["msg1", "msg2"]}`` with a list of per-file errors.
138
+ """
139
+ if isinstance(response_body, dict):
140
+ if "error" in response_body:
141
+ err = response_body["error"]
142
+ msg = "; ".join(err) if isinstance(err, list) else str(err)
143
+ raise classify_error(action=action, message=msg)
144
+ return # empty dict or success dict without "error" key
145
+ if isinstance(response_body, str):
146
+ stripped = response_body.strip()
147
+ if stripped == "" or stripped == "{}":
148
+ return
149
+ # Try to parse as JSON — some actions return JSON error strings
150
+ try:
151
+ parsed = json.loads(stripped)
152
+ if isinstance(parsed, dict) and "error" in parsed:
153
+ err = parsed["error"]
154
+ msg = "; ".join(err) if isinstance(err, list) else str(err)
155
+ raise classify_error(action=action, message=msg)
156
+ return # valid JSON without "error" key
157
+ except json.JSONDecodeError:
158
+ pass
159
+ raise classify_error(action=action, message=stripped)
160
+
161
+
162
+ def parse_expand_response(raw: dict) -> list[DirTree]:
163
+ """Parse a KCFinder expand response into a list of DirTree nodes.
164
+
165
+ The expand action returns ``{"dirs": [{name, writable, hasDirs, ...}]}``.
166
+ Each entry describes a direct subdirectory (no nested children).
167
+ """
168
+ return [
169
+ DirTree(
170
+ name=d["name"],
171
+ is_writable=d.get("writable", False),
172
+ has_subdirs=d.get("hasDirs", False),
173
+ children=[],
174
+ files=[],
175
+ )
176
+ for d in raw.get("dirs", [])
177
+ ]
@@ -0,0 +1,214 @@
1
+ """Async client for KCFinder file manager operations."""
2
+
3
+ from pathlib import Path
4
+ from types import TracebackType
5
+ from urllib.parse import urlencode
6
+
7
+ import httpx
8
+
9
+ from kcfinder_client._core import (
10
+ build_action_url,
11
+ build_form_data,
12
+ build_headers,
13
+ check_action_error,
14
+ check_upload_response,
15
+ parse_dir_tree,
16
+ parse_expand_response,
17
+ parse_file_list,
18
+ prefix_dir,
19
+ prefix_file_paths,
20
+ )
21
+ from kcfinder_client.auth import BaseAuth
22
+ from kcfinder_client.models import DirTree, FileInfo
23
+
24
+
25
+ class AsyncKCFinderClient:
26
+ """Async client for KCFinder file manager operations."""
27
+
28
+ def __init__(
29
+ self,
30
+ browse_url: str,
31
+ auth: BaseAuth,
32
+ file_type: str = "images",
33
+ ) -> None:
34
+ self._browse_url = browse_url
35
+ self._auth = auth
36
+ self._file_type = file_type
37
+ self._client: httpx.AsyncClient | None = None
38
+
39
+ async def __aenter__(self) -> AsyncKCFinderClient:
40
+ self._client = httpx.AsyncClient()
41
+ await self._auth.authenticate(self._client)
42
+ return self
43
+
44
+ async def __aexit__(
45
+ self,
46
+ exc_type: type[BaseException] | None,
47
+ exc_val: BaseException | None,
48
+ exc_tb: TracebackType | None,
49
+ ) -> None:
50
+ if self._client:
51
+ await self._client.aclose()
52
+
53
+ def _get_client(self) -> httpx.AsyncClient:
54
+ """Return the active HTTP client, raising if not initialized."""
55
+ if self._client is None:
56
+ raise RuntimeError(
57
+ "Client not initialized. Use 'async with' context manager."
58
+ )
59
+ return self._client
60
+
61
+ async def _post(self, action: str, data: dict) -> httpx.Response:
62
+ """Send a POST request to KCFinder."""
63
+ url = build_action_url(
64
+ self._browse_url, action, self._file_type, self._auth.get_query_params()
65
+ )
66
+ headers = build_headers(self._auth.get_referer())
67
+ return await self._get_client().post(url, data=data, headers=headers)
68
+
69
+ async def list_files(self, dir: str = "") -> list[FileInfo]:
70
+ """List files in a directory (root by default)."""
71
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
72
+ response = await self._post("chDir", data)
73
+ return parse_file_list(response.json())
74
+
75
+ async def upload(self, dir: str, files: Path | list[Path]) -> None:
76
+ """Upload one or more files to a directory."""
77
+ if isinstance(files, Path):
78
+ files = [files]
79
+ url = build_action_url(
80
+ self._browse_url, "upload", self._file_type, self._auth.get_query_params()
81
+ )
82
+ url += "&" + urlencode({"dir": prefix_dir(self._file_type, dir)})
83
+ headers = build_headers(self._auth.get_referer())
84
+ upload_files = [("upload[]", (f.name, f.read_bytes())) for f in files]
85
+ response = await self._get_client().post(
86
+ url,
87
+ files=upload_files,
88
+ headers=headers,
89
+ )
90
+ check_upload_response(response.text)
91
+
92
+ async def delete(self, dir: str, file: str) -> None:
93
+ """Delete a file."""
94
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
95
+ response = await self._post("delete", data)
96
+ check_action_error("delete", response.text)
97
+
98
+ async def rename(self, dir: str, file: str, new_name: str) -> None:
99
+ """Rename a file."""
100
+ data = build_form_data(
101
+ dir=prefix_dir(self._file_type, dir),
102
+ file=file,
103
+ new_name=new_name,
104
+ )
105
+ response = await self._post("rename", data)
106
+ check_action_error("rename", response.text)
107
+
108
+ async def download(self, dir: str, file: str) -> bytes:
109
+ """Download a file and return its content as bytes."""
110
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
111
+ response = await self._post("download", data)
112
+ return response.content
113
+
114
+ async def get_thumbnail(self, dir: str, file: str) -> bytes:
115
+ """Get the thumbnail for a file as PNG bytes.
116
+
117
+ Thumbnails are served via GET parameters (used as ``<img src>``
118
+ in the browser UI), not POST form data.
119
+ """
120
+ params = {
121
+ **self._auth.get_query_params(),
122
+ "act": "thumb",
123
+ "type": self._file_type,
124
+ "dir": prefix_dir(self._file_type, dir),
125
+ "file": file,
126
+ }
127
+ url = f"{self._browse_url}?{urlencode(params)}"
128
+ headers = build_headers(self._auth.get_referer())
129
+ response = await self._get_client().get(url, headers=headers)
130
+ return response.content
131
+
132
+ async def get_tree(self) -> DirTree:
133
+ """Initialize the browser view and return the root directory node."""
134
+ url = build_action_url(
135
+ self._browse_url, "init", self._file_type, self._auth.get_query_params()
136
+ )
137
+ headers = build_headers(self._auth.get_referer())
138
+ response = await self._get_client().post(url, headers=headers)
139
+ return parse_dir_tree(response.json())
140
+
141
+ async def expand(self, dir: str = "") -> list[DirTree]:
142
+ """Get subdirectory info for a directory (root by default)."""
143
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
144
+ response = await self._post("expand", data)
145
+ return parse_expand_response(response.json())
146
+
147
+ async def create_dir(self, dir: str, new_dir: str) -> None:
148
+ """Create a new subdirectory."""
149
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), new_dir=new_dir)
150
+ response = await self._post("newDir", data)
151
+ check_action_error("newDir", response.text)
152
+
153
+ async def rename_dir(self, dir: str, new_name: str) -> None:
154
+ """Rename a directory."""
155
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), new_name=new_name)
156
+ response = await self._post("renameDir", data)
157
+ check_action_error("renameDir", response.text)
158
+
159
+ async def delete_dir(self, dir: str) -> None:
160
+ """Delete a directory and all its contents recursively.
161
+
162
+ Removes all files and subdirectories within the target directory
163
+ before removing the directory itself.
164
+ """
165
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
166
+ response = await self._post("deleteDir", data)
167
+ check_action_error("deleteDir", response.text)
168
+
169
+ async def download_dir(self, dir: str) -> bytes:
170
+ """Download a directory as a ZIP archive."""
171
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
172
+ response = await self._post("downloadDir", data)
173
+ return response.content
174
+
175
+ async def _bulk_action(
176
+ self, action: str, files: list[str], dest: str | None = None
177
+ ) -> None:
178
+ """Shared logic for bulk copy/move/delete actions.
179
+
180
+ File paths are automatically prefixed with the file type (e.g.,
181
+ ``images/subdir/file.jpg``) as required by the KCFinder protocol.
182
+ """
183
+ prefixed = prefix_file_paths(self._file_type, files)
184
+ data = build_form_data(files=prefixed)
185
+ if dest is not None:
186
+ data["dir"] = prefix_dir(self._file_type, dest)
187
+ response = await self._post(action, data)
188
+ check_action_error(action, response.text)
189
+
190
+ async def copy(self, files: str | list[str], dest: str) -> None:
191
+ """Copy files to a destination directory."""
192
+ if isinstance(files, str):
193
+ files = [files]
194
+ await self._bulk_action("cp_cbd", files, dest)
195
+
196
+ async def move(self, files: str | list[str], dest: str) -> None:
197
+ """Move files to a destination directory."""
198
+ if isinstance(files, str):
199
+ files = [files]
200
+ await self._bulk_action("mv_cbd", files, dest)
201
+
202
+ async def bulk_delete(self, files: list[str]) -> None:
203
+ """Delete multiple files."""
204
+ await self._bulk_action("rm_cbd", files)
205
+
206
+ async def download_selected(self, dir: str, files: list[str]) -> bytes:
207
+ """Download selected files as a ZIP archive.
208
+
209
+ Unlike bulk clipboard operations, ``files`` here are plain
210
+ filenames (basenames) within the specified directory.
211
+ """
212
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), files=files)
213
+ response = await self._post("downloadSelected", data)
214
+ return response.content
@@ -0,0 +1,160 @@
1
+ import os
2
+ from abc import ABC, abstractmethod
3
+ from urllib.parse import urlencode
4
+
5
+ import httpx
6
+
7
+ from kcfinder_client.exceptions import AuthError
8
+
9
+
10
+ class BaseAuth(ABC):
11
+ """Base class for KCFinder authentication strategies."""
12
+
13
+ @abstractmethod
14
+ async def authenticate(self, session: httpx.AsyncClient) -> None:
15
+ """Authenticate an async HTTP session."""
16
+
17
+ @abstractmethod
18
+ def authenticate_sync(self, session: httpx.Client) -> None:
19
+ """Authenticate a sync HTTP session."""
20
+
21
+ @abstractmethod
22
+ def get_referer(self) -> str:
23
+ """Return the Referer URL required for KCFinder requests."""
24
+
25
+ def get_query_params(self) -> dict[str, str]:
26
+ """Return extra query params to include on every KCFinder request.
27
+
28
+ The HarmonySite fork requires ``bros_config`` and ``brosseccheck``
29
+ on every request URL, not just the session init. Standard KCFinder
30
+ installs return an empty dict.
31
+ """
32
+ return {}
33
+
34
+
35
+ class SessionAuth(BaseAuth):
36
+ """Auth for standard KCFinder installs with a pre-established session.
37
+
38
+ Use this when you already have a valid PHPSESSID cookie value.
39
+ """
40
+
41
+ def __init__(self, session_id: str, referer: str) -> None:
42
+ self._session_id = session_id
43
+ self._referer = referer
44
+
45
+ async def authenticate(self, session: httpx.AsyncClient) -> None:
46
+ session.cookies.set("PHPSESSID", self._session_id)
47
+
48
+ def authenticate_sync(self, session: httpx.Client) -> None:
49
+ session.cookies.set("PHPSESSID", self._session_id)
50
+
51
+ def get_referer(self) -> str:
52
+ return self._referer
53
+
54
+
55
+ class HarmonySiteAuth(BaseAuth):
56
+ """Auth for HarmonySite's KCFinder fork.
57
+
58
+ Authenticates in two steps:
59
+ 1. POST to login_url with credentials to establish a web session
60
+ 2. GET browse_url with bros_config and brosseccheck to init the KCFinder session
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ login_url: str,
66
+ browse_url: str,
67
+ username: str,
68
+ password: str,
69
+ bros_config: str,
70
+ brosseccheck: str = "Xx-ok-xX",
71
+ ) -> None:
72
+ self._login_url = login_url
73
+ self._browse_url = browse_url
74
+ self._username = username
75
+ self._password = password
76
+ self._bros_config = bros_config
77
+ self._brosseccheck = brosseccheck
78
+
79
+ def _login_data(self) -> dict[str, str]:
80
+ """Build the HarmonySite login form payload."""
81
+ return {
82
+ "dbase": "users",
83
+ "action": "login",
84
+ "username": self._username,
85
+ "password": self._password,
86
+ "nextpage": self._login_url.rsplit("/", 1)[0],
87
+ "login": "Log In",
88
+ "remember": "1",
89
+ }
90
+
91
+ async def authenticate(self, session: httpx.AsyncClient) -> None:
92
+ login_resp = await session.post(
93
+ self._login_url,
94
+ data=self._login_data(),
95
+ follow_redirects=True,
96
+ )
97
+ if login_resp.status_code != 200:
98
+ raise AuthError(f"Login failed with status {login_resp.status_code}")
99
+
100
+ init_resp = await session.get(self._init_url())
101
+ if init_resp.status_code != 200:
102
+ raise AuthError(
103
+ f"KCFinder session init failed with status {init_resp.status_code}"
104
+ )
105
+
106
+ def authenticate_sync(self, session: httpx.Client) -> None:
107
+ login_resp = session.post(
108
+ self._login_url,
109
+ data=self._login_data(),
110
+ follow_redirects=True,
111
+ )
112
+ if login_resp.status_code != 200:
113
+ raise AuthError(f"Login failed with status {login_resp.status_code}")
114
+
115
+ init_resp = session.get(self._init_url())
116
+ if init_resp.status_code != 200:
117
+ raise AuthError(
118
+ f"KCFinder session init failed with status {init_resp.status_code}"
119
+ )
120
+
121
+ def get_referer(self) -> str:
122
+ return self._init_url()
123
+
124
+ def get_query_params(self) -> dict[str, str]:
125
+ return {
126
+ "bros_config": self._bros_config,
127
+ "brosseccheck": self._brosseccheck,
128
+ }
129
+
130
+ def _init_url(self) -> str:
131
+ params = urlencode(
132
+ {
133
+ "bros_config": self._bros_config,
134
+ "brosseccheck": self._brosseccheck,
135
+ }
136
+ )
137
+ return f"{self._browse_url}?{params}"
138
+
139
+
140
+ def harmonysite_auth_from_env() -> HarmonySiteAuth:
141
+ """Build a HarmonySiteAuth from environment variables.
142
+
143
+ Required env vars:
144
+ KCFINDER_LOGIN_URL: The HarmonySite login URL
145
+ KCFINDER_BROWSE_URL: The KCFinder browse.php URL
146
+ KCFINDER_USERNAME: Login username
147
+ KCFINDER_PASSWORD: Login password
148
+ KCFINDER_BROS_CONFIG: PHP-serialized bros_config string
149
+
150
+ Optional env vars:
151
+ KCFINDER_BROSSECCHECK: Security check token (default: "Xx-ok-xX")
152
+ """
153
+ return HarmonySiteAuth(
154
+ login_url=os.environ["KCFINDER_LOGIN_URL"],
155
+ browse_url=os.environ["KCFINDER_BROWSE_URL"],
156
+ username=os.environ["KCFINDER_USERNAME"],
157
+ password=os.environ["KCFINDER_PASSWORD"],
158
+ bros_config=os.environ["KCFINDER_BROS_CONFIG"],
159
+ brosseccheck=os.environ.get("KCFINDER_BROSSECCHECK", "Xx-ok-xX"),
160
+ )
@@ -0,0 +1,212 @@
1
+ """Sync client for KCFinder file manager operations."""
2
+
3
+ from pathlib import Path
4
+ from types import TracebackType
5
+ from urllib.parse import urlencode
6
+
7
+ import httpx
8
+
9
+ from kcfinder_client._core import (
10
+ build_action_url,
11
+ build_form_data,
12
+ build_headers,
13
+ check_action_error,
14
+ check_upload_response,
15
+ parse_dir_tree,
16
+ parse_expand_response,
17
+ parse_file_list,
18
+ prefix_dir,
19
+ prefix_file_paths,
20
+ )
21
+ from kcfinder_client.auth import BaseAuth
22
+ from kcfinder_client.models import DirTree, FileInfo
23
+
24
+
25
+ class KCFinderClient:
26
+ """Sync client for KCFinder file manager operations."""
27
+
28
+ def __init__(
29
+ self,
30
+ browse_url: str,
31
+ auth: BaseAuth,
32
+ file_type: str = "images",
33
+ ) -> None:
34
+ self._browse_url = browse_url
35
+ self._auth = auth
36
+ self._file_type = file_type
37
+ self._client: httpx.Client | None = None
38
+
39
+ def __enter__(self) -> KCFinderClient:
40
+ self._client = httpx.Client()
41
+ self._auth.authenticate_sync(self._client)
42
+ return self
43
+
44
+ def __exit__(
45
+ self,
46
+ exc_type: type[BaseException] | None,
47
+ exc_val: BaseException | None,
48
+ exc_tb: TracebackType | None,
49
+ ) -> None:
50
+ if self._client:
51
+ self._client.close()
52
+
53
+ def _get_client(self) -> httpx.Client:
54
+ """Return the active HTTP client, raising if not initialized."""
55
+ if self._client is None:
56
+ raise RuntimeError("Client not initialized. Use 'with' context manager.")
57
+ return self._client
58
+
59
+ def _post(self, action: str, data: dict) -> httpx.Response:
60
+ """Send a POST request to KCFinder."""
61
+ url = build_action_url(
62
+ self._browse_url, action, self._file_type, self._auth.get_query_params()
63
+ )
64
+ headers = build_headers(self._auth.get_referer())
65
+ return self._get_client().post(url, data=data, headers=headers)
66
+
67
+ def list_files(self, dir: str = "") -> list[FileInfo]:
68
+ """List files in a directory (root by default)."""
69
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
70
+ response = self._post("chDir", data)
71
+ return parse_file_list(response.json())
72
+
73
+ def upload(self, dir: str, files: Path | list[Path]) -> None:
74
+ """Upload one or more files to a directory."""
75
+ if isinstance(files, Path):
76
+ files = [files]
77
+ url = build_action_url(
78
+ self._browse_url, "upload", self._file_type, self._auth.get_query_params()
79
+ )
80
+ url += "&" + urlencode({"dir": prefix_dir(self._file_type, dir)})
81
+ headers = build_headers(self._auth.get_referer())
82
+ upload_files = [("upload[]", (f.name, f.read_bytes())) for f in files]
83
+ response = self._get_client().post(
84
+ url,
85
+ files=upload_files,
86
+ headers=headers,
87
+ )
88
+ check_upload_response(response.text)
89
+
90
+ def delete(self, dir: str, file: str) -> None:
91
+ """Delete a file."""
92
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
93
+ response = self._post("delete", data)
94
+ check_action_error("delete", response.text)
95
+
96
+ def rename(self, dir: str, file: str, new_name: str) -> None:
97
+ """Rename a file."""
98
+ data = build_form_data(
99
+ dir=prefix_dir(self._file_type, dir),
100
+ file=file,
101
+ new_name=new_name,
102
+ )
103
+ response = self._post("rename", data)
104
+ check_action_error("rename", response.text)
105
+
106
+ def download(self, dir: str, file: str) -> bytes:
107
+ """Download a file and return its content as bytes."""
108
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
109
+ response = self._post("download", data)
110
+ return response.content
111
+
112
+ def get_thumbnail(self, dir: str, file: str) -> bytes:
113
+ """Get the thumbnail for a file as PNG bytes.
114
+
115
+ Thumbnails are served via GET parameters (used as ``<img src>``
116
+ in the browser UI), not POST form data.
117
+ """
118
+ params = {
119
+ **self._auth.get_query_params(),
120
+ "act": "thumb",
121
+ "type": self._file_type,
122
+ "dir": prefix_dir(self._file_type, dir),
123
+ "file": file,
124
+ }
125
+ url = f"{self._browse_url}?{urlencode(params)}"
126
+ headers = build_headers(self._auth.get_referer())
127
+ response = self._get_client().get(url, headers=headers)
128
+ return response.content
129
+
130
+ def get_tree(self) -> DirTree:
131
+ """Initialize the browser view and return the root directory node."""
132
+ url = build_action_url(
133
+ self._browse_url, "init", self._file_type, self._auth.get_query_params()
134
+ )
135
+ headers = build_headers(self._auth.get_referer())
136
+ response = self._get_client().post(url, headers=headers)
137
+ return parse_dir_tree(response.json())
138
+
139
+ def expand(self, dir: str = "") -> list[DirTree]:
140
+ """Get subdirectory info for a directory (root by default)."""
141
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
142
+ response = self._post("expand", data)
143
+ return parse_expand_response(response.json())
144
+
145
+ def create_dir(self, dir: str, new_dir: str) -> None:
146
+ """Create a new subdirectory."""
147
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), new_dir=new_dir)
148
+ response = self._post("newDir", data)
149
+ check_action_error("newDir", response.text)
150
+
151
+ def rename_dir(self, dir: str, new_name: str) -> None:
152
+ """Rename a directory."""
153
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), new_name=new_name)
154
+ response = self._post("renameDir", data)
155
+ check_action_error("renameDir", response.text)
156
+
157
+ def delete_dir(self, dir: str) -> None:
158
+ """Delete a directory and all its contents recursively.
159
+
160
+ Removes all files and subdirectories within the target directory
161
+ before removing the directory itself.
162
+ """
163
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
164
+ response = self._post("deleteDir", data)
165
+ check_action_error("deleteDir", response.text)
166
+
167
+ def download_dir(self, dir: str) -> bytes:
168
+ """Download a directory as a ZIP archive."""
169
+ data = build_form_data(dir=prefix_dir(self._file_type, dir))
170
+ response = self._post("downloadDir", data)
171
+ return response.content
172
+
173
+ def _bulk_action(
174
+ self, action: str, files: list[str], dest: str | None = None
175
+ ) -> None:
176
+ """Shared logic for bulk copy/move/delete actions.
177
+
178
+ File paths are automatically prefixed with the file type (e.g.,
179
+ ``images/subdir/file.jpg``) as required by the KCFinder protocol.
180
+ """
181
+ prefixed = prefix_file_paths(self._file_type, files)
182
+ data = build_form_data(files=prefixed)
183
+ if dest is not None:
184
+ data["dir"] = prefix_dir(self._file_type, dest)
185
+ response = self._post(action, data)
186
+ check_action_error(action, response.text)
187
+
188
+ def copy(self, files: str | list[str], dest: str) -> None:
189
+ """Copy files to a destination directory."""
190
+ if isinstance(files, str):
191
+ files = [files]
192
+ self._bulk_action("cp_cbd", files, dest)
193
+
194
+ def move(self, files: str | list[str], dest: str) -> None:
195
+ """Move files to a destination directory."""
196
+ if isinstance(files, str):
197
+ files = [files]
198
+ self._bulk_action("mv_cbd", files, dest)
199
+
200
+ def bulk_delete(self, files: list[str]) -> None:
201
+ """Delete multiple files."""
202
+ self._bulk_action("rm_cbd", files)
203
+
204
+ def download_selected(self, dir: str, files: list[str]) -> bytes:
205
+ """Download selected files as a ZIP archive.
206
+
207
+ Unlike bulk clipboard operations, ``files`` here are plain
208
+ filenames (basenames) within the specified directory.
209
+ """
210
+ data = build_form_data(dir=prefix_dir(self._file_type, dir), files=files)
211
+ response = self._post("downloadSelected", data)
212
+ return response.content
@@ -0,0 +1,96 @@
1
+ """Exception hierarchy for the KCFinder client."""
2
+
3
+ import re
4
+
5
+
6
+ class KCFinderError(Exception):
7
+ """Base exception for all KCFinder errors."""
8
+
9
+
10
+ class AuthError(KCFinderError):
11
+ """Authentication or session initialization failed."""
12
+
13
+
14
+ class ActionError(KCFinderError):
15
+ """A KCFinder action returned an error in the response body."""
16
+
17
+ def __init__(self, action: str, message: str) -> None:
18
+ self.action = action
19
+ self.message = message
20
+ super().__init__(f"{action}: {message}")
21
+
22
+
23
+ class DirectoryOperationError(ActionError):
24
+ """Directory creation, rename, or deletion failed."""
25
+
26
+
27
+ class FileOperationError(ActionError):
28
+ """File not found, cannot copy/move/delete, name conflict, etc."""
29
+
30
+
31
+ class PermissionDeniedError(ActionError):
32
+ """Operation denied due to insufficient permissions."""
33
+
34
+
35
+ class UploadError(ActionError):
36
+ """One or more files failed to upload."""
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Error message classification
41
+ # ---------------------------------------------------------------------------
42
+ # Patterns derived from the KCFinder PHP source (sunhater/kcfinder).
43
+ # Order matters within each group; groups are checked permission → directory
44
+ # → file → upload, with ActionError as the fallback.
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _PERMISSION_PATTERNS: list[re.Pattern[str]] = [
48
+ re.compile(r"don't have permissions", re.IGNORECASE),
49
+ re.compile(r"Cannot access or write to upload folder", re.IGNORECASE),
50
+ ]
51
+
52
+ _DIR_PATTERNS: list[re.Pattern[str]] = [
53
+ re.compile(r"Cannot (create .+ |rename the |delete the )folder", re.IGNORECASE),
54
+ re.compile(r"enter new folder name", re.IGNORECASE),
55
+ re.compile(r"characters in folder name", re.IGNORECASE),
56
+ re.compile(r"Folder name shouldn't begins with", re.IGNORECASE),
57
+ re.compile(r"Failed to delete .+ files/folders", re.IGNORECASE),
58
+ ]
59
+
60
+ _FILE_PATTERNS: list[re.Pattern[str]] = [
61
+ re.compile(r"does not exist", re.IGNORECASE),
62
+ re.compile(r"Cannot (read|copy|move|delete) '", re.IGNORECASE),
63
+ re.compile(r"cannot rename the extension", re.IGNORECASE),
64
+ re.compile(r"already exists", re.IGNORECASE),
65
+ re.compile(r"enter new file name", re.IGNORECASE),
66
+ re.compile(r"characters in file name", re.IGNORECASE),
67
+ re.compile(r"File name shouldn't begins with", re.IGNORECASE),
68
+ re.compile(r"Denied file extension", re.IGNORECASE),
69
+ ]
70
+
71
+ _UPLOAD_PATTERNS: list[re.Pattern[str]] = [
72
+ re.compile(r"uploaded file", re.IGNORECASE),
73
+ re.compile(r"No file was uploaded", re.IGNORECASE),
74
+ re.compile(r"Missing a temporary folder", re.IGNORECASE),
75
+ re.compile(r"Failed to write file", re.IGNORECASE),
76
+ re.compile(r"Cannot move uploaded file", re.IGNORECASE),
77
+ re.compile(r"too big and/or cannot be resized", re.IGNORECASE),
78
+ re.compile(r"Non-existing directory type", re.IGNORECASE),
79
+ ]
80
+
81
+
82
+ def classify_error(action: str, message: str) -> ActionError:
83
+ """Return the most specific ActionError subclass for a KCFinder error message."""
84
+ for pattern in _PERMISSION_PATTERNS:
85
+ if pattern.search(message):
86
+ return PermissionDeniedError(action=action, message=message)
87
+ for pattern in _DIR_PATTERNS:
88
+ if pattern.search(message):
89
+ return DirectoryOperationError(action=action, message=message)
90
+ for pattern in _FILE_PATTERNS:
91
+ if pattern.search(message):
92
+ return FileOperationError(action=action, message=message)
93
+ for pattern in _UPLOAD_PATTERNS:
94
+ if pattern.search(message):
95
+ return UploadError(action=action, message=message)
96
+ return ActionError(action=action, message=message)
@@ -0,0 +1,36 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+
5
+ @dataclass
6
+ class FileInfo:
7
+ """A file entry returned by KCFinder."""
8
+
9
+ name: str
10
+ size: int
11
+ mtime: datetime
12
+ is_writable: bool
13
+
14
+
15
+ @dataclass
16
+ class DirTree:
17
+ """A directory node from KCFinder's init or expand actions.
18
+
19
+ The server returns only one level of subdirectories at a time.
20
+ Use ``expand()`` to load deeper levels on demand.
21
+ """
22
+
23
+ name: str
24
+ is_writable: bool
25
+ has_subdirs: bool
26
+ children: list[DirTree]
27
+ files: list[FileInfo]
28
+
29
+
30
+ @dataclass
31
+ class SyncResult:
32
+ """Result of a push sync operation."""
33
+
34
+ uploaded: list[str]
35
+ deleted: list[str]
36
+ skipped: list[str]
@@ -0,0 +1,93 @@
1
+ """One-way push sync: make a remote directory match a local directory."""
2
+
3
+ from pathlib import Path
4
+
5
+ from kcfinder_client.async_client import AsyncKCFinderClient
6
+ from kcfinder_client.client import KCFinderClient
7
+ from kcfinder_client.models import FileInfo, SyncResult
8
+
9
+
10
+ def _compute_sync_actions(
11
+ local_files: dict[str, int],
12
+ remote_files: dict[str, int],
13
+ ) -> SyncResult:
14
+ """Compare local and remote file sets by name and size."""
15
+ uploaded = []
16
+ skipped = []
17
+ deleted = []
18
+
19
+ for name, local_size in sorted(local_files.items()):
20
+ if name in remote_files and remote_files[name] == local_size:
21
+ skipped.append(name)
22
+ else:
23
+ uploaded.append(name)
24
+
25
+ for name in sorted(remote_files):
26
+ if name not in local_files:
27
+ deleted.append(name)
28
+
29
+ return SyncResult(uploaded=uploaded, deleted=deleted, skipped=skipped)
30
+
31
+
32
+ def _scan_local_dir(local_dir: Path) -> dict[str, int]:
33
+ """Scan a local directory and return a dict of filename -> size."""
34
+ return {f.name: f.stat().st_size for f in local_dir.iterdir() if f.is_file()}
35
+
36
+
37
+ def _remote_file_map(files: list[FileInfo]) -> dict[str, int]:
38
+ """Convert a list of FileInfo to a dict of filename -> size."""
39
+ return {f.name: f.size for f in files}
40
+
41
+
42
+ class SyncManager:
43
+ """Async one-way push sync manager."""
44
+
45
+ def __init__(self, client: AsyncKCFinderClient) -> None:
46
+ self._client = client
47
+
48
+ async def push(
49
+ self, remote_dir: str, local_dir: Path, *, dry_run: bool = False
50
+ ) -> SyncResult:
51
+ """Sync local directory to remote, making remote match local."""
52
+ remote_files = await self._client.list_files(remote_dir)
53
+ local_map = _scan_local_dir(local_dir)
54
+ remote_map = _remote_file_map(remote_files)
55
+ result = _compute_sync_actions(local_map, remote_map)
56
+
57
+ if dry_run:
58
+ return result
59
+
60
+ for name in result.uploaded:
61
+ await self._client.upload(remote_dir, local_dir / name)
62
+
63
+ for name in result.deleted:
64
+ await self._client.delete(remote_dir, name)
65
+
66
+ return result
67
+
68
+
69
+ class SyncManagerSync:
70
+ """Sync (non-async) one-way push sync manager."""
71
+
72
+ def __init__(self, client: KCFinderClient) -> None:
73
+ self._client = client
74
+
75
+ def push(
76
+ self, remote_dir: str, local_dir: Path, *, dry_run: bool = False
77
+ ) -> SyncResult:
78
+ """Sync local directory to remote, making remote match local."""
79
+ remote_files = self._client.list_files(remote_dir)
80
+ local_map = _scan_local_dir(local_dir)
81
+ remote_map = _remote_file_map(remote_files)
82
+ result = _compute_sync_actions(local_map, remote_map)
83
+
84
+ if dry_run:
85
+ return result
86
+
87
+ for name in result.uploaded:
88
+ self._client.upload(remote_dir, local_dir / name)
89
+
90
+ for name in result.deleted:
91
+ self._client.delete(remote_dir, name)
92
+
93
+ return result
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: kcfinder-client
3
+ Version: 0.2.0
4
+ Summary: Python client library for KCFinder file manager
5
+ Project-URL: Changelog, https://github.com/cyberops7/kcfinder-client/releases
6
+ Project-URL: Issues, https://github.com/cyberops7/kcfinder-client/issues
7
+ Project-URL: Repository, https://github.com/cyberops7/kcfinder-client
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: file-manager,httpx,kcfinder,php
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.14
19
+ Requires-Dist: httpx>=0.28
20
+ Description-Content-Type: text/markdown
21
+
22
+ # kcfinder-client
23
+
24
+ [![CI](https://github.com/cyberops7/kcfinder-client/actions/workflows/ci.yml/badge.svg)](https://github.com/cyberops7/kcfinder-client/actions/workflows/ci.yml)
25
+ ![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
27
+
28
+ A Python client library for [KCFinder](https://github.com/sunhater/kcfinder),
29
+ a PHP web file manager. Wraps KCFinder's HTTP action-dispatch API into a clean
30
+ Python interface with both async and sync clients.
31
+
32
+ > [!NOTE]
33
+ > Designed for and tested against HarmonySite's KCFinder fork.
34
+ > May work with other KCFinder instances via `SessionAuth`, but this
35
+ > has not been tested.
36
+
37
+ ## Table of Contents
38
+
39
+ - [Install](#install)
40
+ - [Quick Start](#quick-start)
41
+ - [Auth from Environment Variables](#auth-from-environment-variables)
42
+ - [Sync Files with SyncManager](#sync-files-with-syncmanager)
43
+ - [Documentation](#documentation)
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ # uv
49
+ uv add kcfinder-client
50
+
51
+ # pip
52
+ pip install kcfinder-client
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ### Async (recommended)
58
+
59
+ ```python
60
+ import asyncio
61
+ import os
62
+ from kcfinder_client import AsyncKCFinderClient, HarmonySiteAuth
63
+
64
+ auth = HarmonySiteAuth(
65
+ login_url="https://example.harmonysong.com/dbaction.php",
66
+ browse_url="https://example.harmonysong.com/kcfinder/browse.php",
67
+ username="admin",
68
+ password="secret",
69
+ bros_config=(
70
+ 'a:3:{s:9:"uploadDir";s:6:"files/";'
71
+ 's:9:"thumbsDir";s:14:"files/.thumbs/";'
72
+ 's:9:"uploadURL";s:0:"";}'
73
+ ),
74
+ )
75
+
76
+ async def main():
77
+ browse_url = "https://example.harmonysong.com/kcfinder/browse.php"
78
+ async with AsyncKCFinderClient(browse_url, auth) as client:
79
+ files = await client.list_files("banners")
80
+ for f in files:
81
+ print(f.name, f.size)
82
+
83
+ asyncio.run(main())
84
+ ```
85
+
86
+ ### Sync
87
+
88
+ ```python
89
+ from kcfinder_client import KCFinderClient, HarmonySiteAuth
90
+
91
+ auth = HarmonySiteAuth(
92
+ login_url="https://example.harmonysong.com/dbaction.php",
93
+ browse_url="https://example.harmonysong.com/kcfinder/browse.php",
94
+ username="admin",
95
+ password="secret",
96
+ bros_config=(
97
+ 'a:3:{s:9:"uploadDir";s:6:"files/";'
98
+ 's:9:"thumbsDir";s:14:"files/.thumbs/";'
99
+ 's:9:"uploadURL";s:0:"";}'
100
+ ),
101
+ )
102
+
103
+ browse_url = "https://example.harmonysong.com/kcfinder/browse.php"
104
+ with KCFinderClient(browse_url, auth) as client:
105
+ files = client.list_files("banners")
106
+ for f in files:
107
+ print(f.name, f.size)
108
+ ```
109
+
110
+ ## Auth from Environment Variables
111
+
112
+ Load credentials from the environment rather than hardcoding them:
113
+
114
+ ```bash
115
+ export KCFINDER_LOGIN_URL=https://example.harmonysong.com/dbaction.php
116
+ export KCFINDER_BROWSE_URL=https://example.harmonysong.com/kcfinder/browse.php
117
+ export KCFINDER_USERNAME=admin
118
+ export KCFINDER_PASSWORD=secret
119
+ export KCFINDER_BROS_CONFIG='a:3:{s:9:"uploadDir";s:6:"files/";s:9:"thumbsDir";s:14:"files/.thumbs/";s:9:"uploadURL";s:0:"";}'
120
+ ```
121
+
122
+ ```python
123
+ import os
124
+ from kcfinder_client import AsyncKCFinderClient, harmonysite_auth_from_env
125
+ import asyncio
126
+
127
+ async def main():
128
+ auth = harmonysite_auth_from_env()
129
+ browse_url = os.environ["KCFINDER_BROWSE_URL"]
130
+ async with AsyncKCFinderClient(browse_url, auth) as client:
131
+ files = await client.list_files()
132
+ print(f"Found {len(files)} files")
133
+
134
+ asyncio.run(main())
135
+ ```
136
+
137
+ ## Sync Files with SyncManager
138
+
139
+ One-way push sync to make a remote directory match a local one:
140
+
141
+ ```python
142
+ import os
143
+ from pathlib import Path
144
+ from kcfinder_client import KCFinderClient, SyncManagerSync, harmonysite_auth_from_env
145
+
146
+ auth = harmonysite_auth_from_env()
147
+ browse_url = os.environ["KCFINDER_BROWSE_URL"]
148
+
149
+ with KCFinderClient(browse_url, auth) as client:
150
+ sync = SyncManagerSync(client)
151
+
152
+ # Dry run first to preview changes
153
+ result = sync.push("banners", Path("./banners"), dry_run=True)
154
+ print(f"Would upload: {result.uploaded}")
155
+ print(f"Would delete: {result.deleted}")
156
+ print(f"Would skip: {result.skipped}")
157
+
158
+ # Apply the sync
159
+ result = sync.push("banners", Path("./banners"))
160
+ ```
161
+
162
+ ## Documentation
163
+
164
+ - [Auth setup](docs/auth.md) — HarmonySiteAuth, SessionAuth, env vars
165
+ - [File operations](docs/files.md) — list, upload, download, delete, rename,
166
+ thumbnails
167
+ - [Directory operations](docs/directories.md) — tree, expand, create, rename,
168
+ delete
169
+ - [Bulk operations](docs/bulk.md) — copy, move, bulk delete, download selected
170
+ - [Sync manager](docs/sync.md) — push sync, dry run, SyncResult
171
+ - [Exceptions](docs/exceptions.md) — error handling, exception hierarchy
172
+
173
+ ## License
174
+
175
+ MIT
@@ -0,0 +1,12 @@
1
+ kcfinder_client/__init__.py,sha256=WJ10BvBqmT5z6P1LpHDHTRp_-0L8BDWJVBvQ9mp__nY,1013
2
+ kcfinder_client/_core.py,sha256=J5DOjXHCqD8MGTbEJE_hoaQgUzfaFvkDVEpPo8GeBq4,6137
3
+ kcfinder_client/async_client.py,sha256=PQ64rKyqGkfv9uzE60aWTOhc_knz0KM9hKji-i7fnoE,8321
4
+ kcfinder_client/auth.py,sha256=-HMW3cn9_zqP6kZnjibKOtcqPX0U723cdcQxtEt3Tg8,5189
5
+ kcfinder_client/client.py,sha256=QlqMhtOeoetn1DKPGg1cn8ZtmX2x-30jibwYvcyIdRo,8020
6
+ kcfinder_client/exceptions.py,sha256=aMEGtAFOr46akKc-Qh2Ey9CFyYIDfBc25lDLWiCptZk,3706
7
+ kcfinder_client/models.py,sha256=HEOOQPNQBLH6e4UOd-SOXdmdZfU6iZegKRlLH4NiSLw,689
8
+ kcfinder_client/sync.py,sha256=eJUnK2CWHSlNi1gkATmqrvPq0KZuXJuQV2B0SrEFzJw,2941
9
+ kcfinder_client-0.2.0.dist-info/METADATA,sha256=5e0nwQOtt0H_ZWlGdbuhomVrU-h3Fvw_4LdYMgSNJPs,5403
10
+ kcfinder_client-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ kcfinder_client-0.2.0.dist-info/licenses/LICENSE,sha256=ynoj1mLUphGX7jiRCgzPXIyhNQRpz5J1oLVumMoFsus,1066
12
+ kcfinder_client-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cyberops7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.