Skip to content

import fcntl makes the CLI completely unusable on Windows (regression in v0.2.0) #49

Description

@leotulipan

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.pyconfig.py, and version.pyconfig.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.py had no platform-specific code. It only used
    Path.write_text / Path.read_text and Path.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

  1. Hard blocker — import fcntl (line 1). This alone crashes the import on Windows.
    It must not be a top-level unconditional import.

  2. _config_lock() uses fcntl.flock. Even with the import guarded, the cross-process
    advisory lock needs a Windows code path (msvcrt.locking) or a library.

  3. Secondary (won't crash, but the intent breaks silently on Windows):

    • _ensure_private_dir calls path.chmod(0o700). On Windows chmod only toggles the
      read-only bit; 0o700 does nothing meaningful, so the "owner-only" guarantee does not hold.
    • _write_private / _atomic_write_text pass a POSIX mode (0o600 / 0o644) to os.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 with os.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")):
        yield

Offer

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions