From a368fc66b35e2fbc39e5109dc32da32777299a6a Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 6 Feb 2026 18:57:00 +0000 Subject: [PATCH] tail/follow: add windows support; closes #1262 --- README.md | 2 ++ copyparty/__main__.py | 8 ++++++-- copyparty/httpcli.py | 18 ++++++++++++----- copyparty/util.py | 45 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 30558044..86703780 100644 --- a/README.md +++ b/README.md @@ -2424,6 +2424,7 @@ buggy feature? rip it out by setting any of the following environment variables | env-var | what it does | | -------------------- | ------------ | +| `PRTY_NO_CTYPES` | do not use features from external libraries such as kernel32 | | `PRTY_NO_DB_LOCK` | do not lock session/shares-databases for exclusive access | | `PRTY_NO_IFADDR` | disable ip/nic discovery by poking into your OS with ctypes | | `PRTY_NO_IMPRESO` | do not try to load js/css files using `importlib.resources` | @@ -3088,6 +3089,7 @@ set any of the following environment variables to disable its associated optiona | `PRTY_NO_PILF` | disable Pillow `ImageFont` text rendering, used for folder thumbnails | | `PRTY_NO_PIL_AVIF` | disable Pillow avif support (internal and/or [plugin](https://pypi.org/project/pillow-avif-plugin/)) | | `PRTY_NO_PIL_HEIF` | disable 3rd-party Pillow plugin for [HEIF support](https://pypi.org/project/pillow-heif/) | +| `PRTY_NO_PIL_JXL` | disable 3rd-party Pillow plugin for [JXL support](https://pypi.org/project/pillow-jxl-plugin/) | | `PRTY_NO_PIL_WEBP` | disable use of native webp support in Pillow | | `PRTY_NO_PSUTIL` | do not use [psutil](https://pypi.org/project/psutil/) for reaping stuck hooks and plugins on Windows | | `PRTY_NO_PYFTPD` | disable ftp(s) server ([pyftpdlib](https://pypi.org/project/pyftpdlib/)-based) | diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 7653452d..b1170478 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -63,6 +63,7 @@ from .util import ( Daemon, align_tab, b64enc, + ctypes, dedent, has_resource, load_resource, @@ -70,6 +71,7 @@ from .util import ( pybin, read_utf8, termsize, + wk32, wrap, ) @@ -504,8 +506,10 @@ def sighandler(sig: Optional[int] = None, frame: Optional[FrameType] = None) -> def disable_quickedit() -> None: + if not ctypes: + raise Exception("no ctypes") + import atexit - import ctypes from ctypes import wintypes def ecb(ok: bool, fun: Any, args: list[Any]) -> list[Any]: @@ -515,10 +519,10 @@ def disable_quickedit() -> None: raise ctypes.WinError(err) # type: ignore return args - k32 = ctypes.WinDLL(str("kernel32"), use_last_error=True) # type: ignore if PY2: wintypes.LPDWORD = ctypes.POINTER(wintypes.DWORD) + k32 = wk32 k32.GetStdHandle.errcheck = ecb # type: ignore k32.GetConsoleMode.errcheck = ecb # type: ignore k32.SetConsoleMode.errcheck = ecb # type: ignore diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 85e25736..c8c05215 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -90,6 +90,7 @@ from .util import ( loadpy, log_reloc, min_ex, + open_nolock, pathmod, quotep, rand_name, @@ -4833,7 +4834,7 @@ class HttpCli(object): f = None try: st = os.stat(abspath) - f = open(*open_args) + f = open_nolock(*open_args) f.seek(0, os.SEEK_END) eof = f.tell() f.seek(0) @@ -4867,6 +4868,7 @@ class HttpCli(object): pass gone = 0 + unsent = False t_fd = t_ka = time.time() while True: assert f # !rm @@ -4883,6 +4885,7 @@ class HttpCli(object): t_fd = t_ka = now self.s.sendall(buf) sent += len(buf) + unsent = False dls[dl_id] = (time.time(), sent) continue @@ -4893,14 +4896,16 @@ class HttpCli(object): if t_fd < now - sec_fd: try: st2 = os.stat(open_args[0]) + szd = st2.st_size - st.st_size if ( st2.st_ino != st.st_ino or st2.st_size < sent - or st2.st_size < st.st_size + or szd < 0 + or unsent ): assert f # !rm # open new file before closing previous to avoid toctous (open may fail; cannot null f before) - f2 = open(*open_args) + f2 = open_nolock(*open_args) f.close() f = f2 f.seek(0, os.SEEK_END) @@ -4915,7 +4920,10 @@ class HttpCli(object): ofs = sent # just new fd? resume from same ofs f.seek(ofs) self.log("reopened at byte %d: %r" % (ofs, abspath), 6) + unsent = False gone = 0 + elif szd: + unsent = True st = st2 except: gone += 1 @@ -5029,7 +5037,7 @@ class HttpCli(object): self.log("moved to tier %d (%s)" % (tier, tiers[tier])) try: - with open(ap_data, "rb", self.args.iobuf) as f: + with open_nolock(ap_data, "rb", self.args.iobuf) as f: f.seek(lower) page = f.read(min(winsz, data_end - lower, upper - lower)) if not page: @@ -5062,7 +5070,7 @@ class HttpCli(object): break if lower < upper and not broken: - with open(req_path, "rb") as f: + with open_nolock(req_path, "rb") as f: remains = sendfile_py( self.log, lower, diff --git a/copyparty/util.py b/copyparty/util.py index fd99ef4b..78d403f2 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -141,11 +141,23 @@ except: HAVE_FICLONE = False try: - import ctypes import termios except: pass +try: + if os.environ.get("PRTY_NO_CTYPES"): + raise Exception() + + import ctypes +except: + ctypes = None + +try: + wk32 = ctypes.WinDLL(str("kernel32"), use_last_error=True) # type: ignore +except: + wk32 = None + try: if os.environ.get("PRTY_NO_IFADDR"): raise Exception() @@ -2887,7 +2899,7 @@ def get_df(abspath: str, prune: bool) -> tuple[int, int, str]: bfree = ctypes.c_ulonglong(0) btotal = ctypes.c_ulonglong(0) bavail = ctypes.c_ulonglong(0) - ctypes.windll.kernel32.GetDiskFreeSpaceExW( # type: ignore + wk32.GetDiskFreeSpaceExW( # type: ignore ctypes.c_wchar_p(abspath), ctypes.pointer(bavail), ctypes.pointer(btotal), @@ -4281,8 +4293,8 @@ def termsize() -> tuple[int, int]: def hidedir(dp) -> None: if ANYWIN: try: - assert ctypes # type: ignore # !rm - k32 = ctypes.WinDLL("kernel32") + assert wk32 # type: ignore # !rm + k32 = wk32 attrs = k32.GetFileAttributesW(dp) if attrs >= 0: k32.SetFileAttributesW(dp, attrs | 2) @@ -4357,6 +4369,31 @@ else: lock_file = _lock_file_noop +def _open_nolock_windows(bap: Union[str, bytes], *a, **ka) -> typing.BinaryIO: + assert ctypes # !rm + assert wk32 # !rm + import msvcrt + + try: + ap = bap.decode("utf-8", "replace") # type: ignore + except: + ap = bap + + fh = wk32.CreateFileW(ap, 0x80000000, 7, None, 3, 0x80, None) + # `-ap, GENERIC_READ, FILE_SHARE_READ|WRITE|DELETE, None, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None + if fh == -1: + ec = ctypes.get_last_error() # type: ignore + raise ctypes.WinError(ec) # type: ignore + fd = msvcrt.open_osfhandle(fh, os.O_RDONLY) # type: ignore + return os.fdopen(fd, "rb") + + +if ANYWIN: + open_nolock = _open_nolock_windows +else: + open_nolock = open + + try: if sys.version_info < (3, 10) or os.environ.get("PRTY_NO_IMPRESO"): # py3.8 doesn't have .files