|
1 | 1 | import logging |
2 | 2 | import os |
3 | | -import io |
4 | 3 | import re |
5 | 4 | import sys |
6 | 5 | import shutil |
7 | 6 | import tempfile |
| 7 | +import subprocess |
8 | 8 |
|
9 | | -import rclone |
10 | 9 | from statemachine import State, StateMachine |
11 | 10 |
|
12 | 11 | logger = logging.getLogger("rclone_decrypt") |
|
15 | 14 | os.path.expanduser("~"), "Downloads", "rclone-decrypted" |
16 | 15 | ) |
17 | 16 |
|
| 17 | +try: |
| 18 | + if shutil.which("rclone"): |
| 19 | + # Get the rclone config file path dynamically |
| 20 | + cmd = ["rclone", "config", "file"] |
| 21 | + out = subprocess.check_output(cmd).decode().strip() |
| 22 | + # The output format is usually: |
| 23 | + # "Configuration file is stored at:\n/path/to/rclone.conf" |
| 24 | + # We need to parse the last line |
| 25 | + default_rclone_conf_dir = out.splitlines()[-1] |
| 26 | + else: |
| 27 | + raise FileNotFoundError("rclone executable not found") |
| 28 | +except (subprocess.CalledProcessError, FileNotFoundError): |
| 29 | + # Fallback to the previous default if rclone command fails or is not found |
| 30 | + default_rclone_conf_dir = os.path.join( |
| 31 | + os.environ["HOME"], ".config", "rclone", "rclone.conf" |
| 32 | + ) |
| 33 | + |
18 | 34 | if sys.platform == "win32": |
19 | 35 | # Windows default: %APPDATA%/rclone/rclone.conf |
20 | 36 | default_rclone_conf_dir = os.path.join( |
@@ -91,60 +107,32 @@ def before_is_valid(self, line: str) -> None: |
91 | 107 | self.cfg_file.write(line) |
92 | 108 |
|
93 | 109 |
|
94 | | -class SafeRClone(rclone.RClone): |
95 | | - """ |
96 | | - A subclass of rclone.RClone that uses delete=False for the temporary |
97 | | - config file. This prevents file locking issues on Windows where the file |
98 | | - cannot be opened by the subprocess while it is still open in Python. |
99 | | - """ |
100 | | - |
101 | | - def run_cmd(self, command, extra_args=None): |
102 | | - if extra_args is None: |
103 | | - extra_args = [] |
104 | | - |
105 | | - # Create a named temporary file, but don't delete it automatically |
106 | | - # on close, so we can close it before passing to rclone. |
107 | | - with tempfile.NamedTemporaryFile(mode="wt", delete=False) as cfg_file: |
108 | | - cfg_file_path = cfg_file.name |
109 | | - try: |
110 | | - self.log.debug("rclone config: ~%s~", self.cfg) |
111 | | - cfg_file.write(self.cfg) |
112 | | - cfg_file.flush() |
113 | | - # Close the file so other processes can access it (Windows fix) |
114 | | - cfg_file.close() |
115 | | - |
116 | | - command_with_args = [ |
117 | | - "rclone", |
118 | | - command, |
119 | | - "--config", |
120 | | - cfg_file_path, |
121 | | - ] |
122 | | - command_with_args += extra_args |
123 | | - command_result = self._execute(command_with_args) |
124 | | - return command_result |
125 | | - finally: |
126 | | - # Manually clean up the file |
127 | | - if os.path.exists(cfg_file_path): |
128 | | - os.remove(cfg_file_path) |
129 | | - |
130 | | - |
131 | | -def get_rclone_instance( |
| 110 | +def get_rclone_config_path( |
132 | 111 | config: str, files: str, remote_folder_name: str |
133 | | -) -> rclone.RClone: |
| 112 | +) -> str: |
134 | 113 | """ |
135 | 114 | Opens a config file and strips out all of the non-crypt type entries, |
136 | 115 | modifies the remote to be local directory. |
137 | 116 |
|
138 | | - Returns an rclone instance. |
| 117 | + Returns the path to the temporary rclone config file. |
139 | 118 | """ |
140 | | - rclone_instance = None |
| 119 | + config_path = None |
141 | 120 |
|
142 | 121 | try: |
143 | 122 | with open(config, "r") as f: |
144 | 123 | config_file = f.readlines() |
145 | 124 |
|
146 | | - with io.StringIO() as tmp_config_file: |
147 | | - config_state = ConfigWriterControl(tmp_config_file) |
| 125 | + # Create a temporary file that persists until manually deleted or |
| 126 | + # cleaned up by caller. |
| 127 | + # We use delete=False so we can return the path and use it later. |
| 128 | + # It will be created in the system temp dir or temp_dir_name if |
| 129 | + # passed? Actually, let's create it inside remote_folder_name |
| 130 | + # (which is a temp dir). |
| 131 | + |
| 132 | + config_path = os.path.join(remote_folder_name, "rclone.conf") |
| 133 | + |
| 134 | + with open(config_path, "w") as config_out: |
| 135 | + config_state = ConfigWriterControl(config_out) |
148 | 136 |
|
149 | 137 | for line in config_file: |
150 | 138 | state_id = config_state.current_state.id |
@@ -186,36 +174,39 @@ def get_rclone_instance( |
186 | 174 |
|
187 | 175 | config_state.complete() |
188 | 176 |
|
189 | | - # Get the content |
190 | | - o = tmp_config_file.getvalue() |
191 | | - # Use our SafeRClone instead of rclone.with_config |
192 | | - rclone_instance = SafeRClone(cfg=o) |
193 | | - |
194 | | - # I think that given a file, any file, rclone.with_config() will always |
195 | | - # return _something_ as it doesn't validate the config file |
196 | | - if rclone_instance is None: |
197 | | - raise ConfigFileError("The rclone instance was not created.") |
198 | | - |
199 | 177 | except FileNotFoundError as err: |
200 | 178 | print_error(err) |
| 179 | + return None |
201 | 180 |
|
202 | | - return rclone_instance |
| 181 | + return config_path |
203 | 182 |
|
204 | 183 |
|
205 | | -def rclone_copy(rclone_instance: rclone.RClone, output_dir: str) -> None: |
| 184 | +def rclone_copy(config_path: str, output_dir: str) -> None: |
206 | 185 | """ |
207 | 186 | Calls the rclone copy function via a shell instance and places the |
208 | 187 | decrypted files into the output_dir |
209 | 188 | """ |
210 | 189 | # convert list of remotes in str format into a list |
211 | | - remotes = rclone_instance.listremotes()["out"].decode().splitlines() |
| 190 | + list_cmd = ["rclone", "--config", config_path, "listremotes"] |
| 191 | + try: |
| 192 | + out = subprocess.check_output(list_cmd).decode() |
| 193 | + remotes = out.splitlines() |
| 194 | + except subprocess.CalledProcessError as e: |
| 195 | + print_error(f"Failed to list remotes: {e}") |
| 196 | + return |
212 | 197 |
|
213 | 198 | for r in remotes: |
214 | | - logger.info(f"Copying and decrypting: {r}") |
215 | | - result = rclone_instance.copy(f"{r}", f"{output_dir}") |
216 | | - if result["code"] != 0: |
217 | | - error_msg = result["error"].decode("utf-8").strip() |
218 | | - logger.warning(f"Failed to decrypt {r}. Rclone error: {error_msg}") |
| 199 | + print(f"Copying and decrypting: {r}") |
| 200 | + copy_cmd = [ |
| 201 | + "rclone", |
| 202 | + "--config", |
| 203 | + config_path, |
| 204 | + "copy", |
| 205 | + f"{r}", |
| 206 | + f"{output_dir}", |
| 207 | + ] |
| 208 | + # TODO(@mitchellthompkins): check return code for success |
| 209 | + subprocess.run(copy_cmd, check=True) |
219 | 210 |
|
220 | 211 |
|
221 | 212 | def decrypt( |
@@ -250,13 +241,31 @@ def decrypt( |
250 | 241 | ) as temp_dir_name: |
251 | 242 | # Ensure path uses forward slashes for rclone config |
252 | 243 | # compatibility on Windows |
| 244 | + # Although update branch didn't have this, it's safer to keep it |
| 245 | + # if we are creating paths for rclone. |
| 246 | + # But get_rclone_config_path writes the config file now, |
| 247 | + # and it writes {remote_folder_name}/ which is the temp dir. |
| 248 | + # So normalization might be good. |
| 249 | + # However, HEAD passed normalized_temp_dir to get_rclone_instance. |
| 250 | + # update branch passed temp_dir_name directly. |
| 251 | + # I will pass temp_dir_name directly to be safe with update logic, |
| 252 | + # or normalized if I think it helps. HEAD thought it helped. |
| 253 | + # I'll stick to temp_dir_name to minimize risk of path mismatch, |
| 254 | + # unless I see a reason. |
| 255 | + # Actually, HEAD comment says "compatibility on Windows". |
| 256 | + # I'll add the normalization back if I see it's used in config writing. |
| 257 | + # In update logic: config_state.write(f"remote = {remote_folder_name}/\n") |
| 258 | + # If remote_folder_name has backslashes, rclone config might barf? |
| 259 | + # Rclone usually handles both, but forward slashes are safer. |
| 260 | + # I will normalize it. |
253 | 261 | normalized_temp_dir = temp_dir_name.replace(os.sep, "/") |
254 | | - rclone_instance = get_rclone_instance( |
| 262 | + |
| 263 | + config_path = get_rclone_config_path( |
255 | 264 | config, files, normalized_temp_dir |
256 | 265 | ) |
257 | 266 |
|
258 | | - if rclone_instance is None: |
259 | | - raise ConfigFileError("rclone_instance cannot be None") |
| 267 | + if config_path is None: |
| 268 | + raise ConfigFileError("config_path cannot be None") |
260 | 269 |
|
261 | 270 | if output_dir is default_output_dir: |
262 | 271 | # If no output_dir is provided, put the de-crypted file into a |
@@ -284,7 +293,7 @@ def decrypt( |
284 | 293 | # Do the copy, we wrap this in a try in case the user |
285 | 294 | # interrupts the process, otherwise the file won't be |
286 | 295 | # moved back |
287 | | - rclone_copy(rclone_instance, output_dir) |
| 296 | + rclone_copy(config_path, output_dir) |
288 | 297 | logger.info( |
289 | 298 | f"Decryption complete. Files saved to: {output_dir}" |
290 | 299 | ) |
|
0 commit comments