Repository navigation
import fcntl makes the CLI completely unusable on Windows (regression in v0.2.0) #49
Description
Since v0.2.0, ytstudio-cli cannot run at all on Windows. src/ytstudio/config.py
does import fcntl at module top level. fcntl is a Unix-only module in the Python
standard library, so the import fails on Windows before any command can execute — including
ytstudio init, ytstudio login, ytstudio status, and every videos / analytics /
comments subcommand.
config.py is imported transitively by basically everything (main.py →
api.py → config.py, and version.py → config.py), so there is no command that works.
Environment
- OS: Windows 11 (cmd / PowerShell)
- Python: 3.12 / 3.13
- ytstudio-cli: every release from v0.2.0 through v0.4.1 (current latest)
- Last working release on Windows: v0.1.1
Reproduction
> ytstudio login
Traceback (most recent call last):
...
File "...\site-packages\ytstudio\main.py", line 6, in <module>
from ytstudio.api import authenticate, get_status
File "...\site-packages\ytstudio\api.py", line 12, in <module>
from ytstudio.config import (
File "...\site-packages\ytstudio\config.py", line 1, in <module>
import fcntl
ModuleNotFoundError: No module named 'fcntl'
When it changed
This is a regression introduced by the profiles refactor.
-
v0.1.1 (last good):
config.pyhad no platform-specific code. It only used
Path.write_text/Path.read_textandPath.mkdir, all cross-platform:# src/ytstudio/config.py @ v0.1.1 import json from pathlib import Path from rich.prompt import Prompt from ytstudio.ui import console, success_message CONFIG_DIR = Path.home() / ".config" / "ytstudio-cli" CLIENT_SECRETS_FILE = CONFIG_DIR / "client_secrets.json" CREDENTIALS_FILE = CONFIG_DIR / "credentials.json" def ensure_config_dir() -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) def save_credentials(credentials: dict) -> None: ensure_config_dir() CREDENTIALS_FILE.write_text(json.dumps(credentials, indent=2)) def load_credentials() -> dict | None: if not CREDENTIALS_FILE.exists(): return None return json.loads(CREDENTIALS_FILE.read_text())
-
v0.2.0 (regression introduced): the profiles rewrite added
import fcntl, an
fcntl.flock-based_config_lock(), and POSIX permission calls. The relevant new code
(unchanged through v0.4.1) is:# src/ytstudio/config.py @ v0.2.0 .. v0.4.1 import fcntl # <-- Unix only: ModuleNotFoundError on Windows import json import os import re import shutil from contextlib import contextmanager from pathlib import Path ... def _ensure_private_dir(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) path.chmod(0o700) # <-- POSIX permission semantics; no-op on Windows def _write_private(path: Path, text: str) -> None: """Atomically write a secret with owner-only permissions, with no readable window.""" tmp = path.with_name(path.name + ".tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # <-- mode ignored on Windows try: os.write(fd, text.encode()) os.fsync(fd) finally: os.close(fd) tmp.replace(path) @contextmanager def _config_lock(): """Serialize state mutations and the legacy migration across CLI invocations.""" ensure_config_dir() lock_path = CONFIG_DIR / ".lock" with lock_path.open("w") as fh: try: fcntl.flock(fh.fileno(), fcntl.LOCK_EX) # <-- Unix only yield finally: fcntl.flock(fh.fileno(), fcntl.LOCK_UN) # <-- Unix only
config.py is the only file in the package that touches fcntl, so the fix is localized.
What needs to change for Windows support
-
Hard blocker —
import fcntl(line 1). This alone crashes the import on Windows.
It must not be a top-level unconditional import. -
_config_lock()usesfcntl.flock. Even with the import guarded, the cross-process
advisory lock needs a Windows code path (msvcrt.locking) or a library. -
Secondary (won't crash, but the intent breaks silently on Windows):
_ensure_private_dircallspath.chmod(0o700). On Windowschmodonly toggles the
read-only bit;0o700does nothing meaningful, so the "owner-only" guarantee does not hold._write_private/_atomic_write_textpass a POSIX mode (0o600/0o644) toos.open;
these bits are ignored on Windows. The atomic-write logic itself is fine cross-platform.
These two are not required to make the CLI run on Windows; they only affect the
permission-hardening intent. They can be guarded withos.name == "posix"and documented,
or left as-is.
Proposed fix
Make the lock and the import cross-platform. Minimal version using only the standard library:
import contextlib
import os
try:
import fcntl # POSIX
except ImportError:
fcntl = None
try:
import msvcrt # Windows
except ImportError:
msvcrt = None
def _lock_file(fh) -> None:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
elif msvcrt is not None:
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
# else: no advisory locking available; degrade gracefully (single-user CLI)
def _unlock_file(fh) -> None:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
elif msvcrt is not None:
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
@contextlib.contextmanager
def _config_lock():
ensure_config_dir()
lock_path = CONFIG_DIR / ".lock"
with lock_path.open("w") as fh:
_lock_file(fh)
try:
yield
finally:
_unlock_file(fh)And guard the POSIX permission calls:
def _ensure_private_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
if os.name == "posix":
path.chmod(0o700)(The os.open(..., 0o600) mode bits in _write_private are already ignored on Windows,
so no crash — but for clarity you may want a comment noting the permission guarantee is
POSIX-only.)
Alternatively, a single dependency such as filelock
gives a robust cross-platform lock and removes all the platform branching:
from filelock import FileLock
@contextlib.contextmanager
def _config_lock():
ensure_config_dir()
with FileLock(str(CONFIG_DIR / ".lock")):
yieldOffer
If you're open to it, I'm happy to put together a PR implementing the cross-platform locking
(either the stdlib msvcrt approach or filelock, your preference) plus the guarded
permission calls, and verify it on Windows. Just let me know which direction you'd like and
I'll have it coded up.
Activity
jdwit commentedon Jun 30, 2026
Thanks for the excellent write-up @leotulipan - the root-cause analysis, the v0.1.1-vs-v0.2.0 diff, and the localized scope made this trivial to fix.
I've gone with the stdlib route (no extra dependency) in #51:
fcntl/msvcrtimported conditionally,_config_lock()routes through_lock_file/_unlock_file(fcntl.flockon POSIX,msvcrt.lockingon Windows, graceful no-op otherwise),chmod(0o700)guarded behindos.name == "posix".Tests cover the simulated-Windows path (fcntl patched to None -> msvcrt.locking) plus the degrade case; full suite green.
Since I don't have a Windows box handy, would you be willing to install the
fix/windows-fcntl-cross-platform-lockbranch and confirmytstudio status/loginactually run on Windows 11? That's the one thing my CI can't verify yet. Much appreciated either way - thanks for flagging it.