Pyright-driven cleanup#380
Conversation
Add a shared platform compatibility layer for portalocker-backed file locks and POSIX-only user helpers, wire Pyright into Pixi, and update tests to use the shared lock abstraction. Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Codex <codex@openai.com>
Behaviorally identical (p is param for that key), just aligns with the surrounding p.flag/p.type usage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
filestore.py still called pwd/grp directly (via optional_module). Move that logic behind new platform_compat helpers groupname_for_gid and group_names_for_user (mirroring the existing username_for_uid), so the POSIX user/group database is accessed in one place. Also revert the earlier p.flag change in build_command: param.flag is intentional there — the `if param.flag is None: continue` guard narrows it to str for list.append, which p.flag (rebound from the dict) lacks. Update TestGetUserGroups to patch platform_compat.optional_module instead of the removed filestore.pwd/grp module globals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_poll_local_jobs used os.kill(pid, 0) as a liveness probe. On Windows os.kill routes through TerminateProcess, so it would kill the target process instead of inspecting it, and a recycled/lingering PID handle made it succeed for already-exited processes — breaking the two TestPollLocalJobs exited-process tests in CI. Add platform_compat.process_is_alive(): POSIX keeps the os.kill(pid, 0) probe (ProcessLookupError -> dead, PermissionError -> alive); Windows uses a non-destructive OpenProcess(SYNCHRONIZE) + WaitForSingleObject check. Collapse the success/PermissionError branches in _poll_local_jobs into a single is-alive path (behavior unchanged on POSIX). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Windows the os.name != "posix" early return ran before optional_module
was consulted, so TestGetUserGroups (which patches optional_module with
mock grp/pwd) got an empty set and failed in CI. The os.name guard was
redundant: on real Windows optional_module("grp") already returns None
since grp can't be imported. Drop the guard and rely on that — identical
behavior on real platforms, and the mocked tests now run cross-platform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@mkitti I'd appreciate your thoughts on this one. |
mkitti
left a comment
There was a problem hiding this comment.
If we are going to use typing, dict[str, Any] is probably something we want to move away from when possible. It would be be better to create a dataclass with an asdict() method. You could also consider using collections.abc.Mapping.
|
|
||
|
|
||
| def _normalize_task(defn) -> dict: | ||
| def _normalize_task(defn: Any) -> dict[str, Any]: |
There was a problem hiding this comment.
There should be some point where the dict definition becomes more strict. Could we constrain the value type here?
|
@d-v-b suggested looking into |
|
please use typeddicts when you know something about the keys / values of your dicts 🙏 |
mkitti
left a comment
There was a problem hiding this comment.
Use | None rather than Optional per https://pylint.readthedocs.io/en/latest/user_guide/messages/refactor/consider-alternative-union-syntax.html
|
|
||
|
|
||
| def _convert_task_arg(arg) -> AppParameter: | ||
| def _convert_task_arg(arg: Any) -> AppParameter: |
There was a problem hiding this comment.
| def _convert_task_arg(arg: Any) -> AppParameter: | |
| def _convert_task_arg(arg: str | Dict[str, Any]) -> AppParameter: |
|
|
||
|
|
||
| def get_proxied_paths(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[ProxiedPathDB]: | ||
| def get_proxied_paths(session: Session, username: str, fsp_name: Optional[str] = None, path: Optional[str] = None) -> List[ProxiedPathDB]: |
There was a problem hiding this comment.
| def get_proxied_paths(session: Session, username: str, fsp_name: Optional[str] = None, path: Optional[str] = None) -> List[ProxiedPathDB]: | |
| def get_proxied_paths(session: Session, username: str, fsp_name: str | None = None, path: str | None = None) -> List[ProxiedPathDB]: |
https://typing.python.org/en/latest/guides/modernizing.html
While Union and Optional are not considered obsolete, using the | (pipe) operator is often more readable.
|
|
||
|
|
||
| def get_tickets(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[TicketDB]: | ||
| def get_tickets(session: Session, username: str, fsp_name: Optional[str] = None, path: Optional[str] = None) -> List[TicketDB]: |
There was a problem hiding this comment.
| def get_tickets(session: Session, username: str, fsp_name: Optional[str] = None, path: Optional[str] = None) -> List[TicketDB]: | |
| def get_tickets(session: Session, username: str, fsp_name: str | None = None, path: str | None = None) -> List[TicketDB]: |
| def from_stat(cls, path: str, absolute_path: str, | ||
| lstat_result: os.stat_result, stat_result: os.stat_result, | ||
| current_user: str = None, fsps: Optional[list] = None, | ||
| current_user: Optional[str] = None, fsps: Optional[list] = None, |
There was a problem hiding this comment.
| current_user: Optional[str] = None, fsps: Optional[list] = None, | |
| current_user: str | None] = None, fsps: list | None = None, |
| root_path: Optional[str] = None, | ||
| user_groups: Optional[set[str]] = None): |
There was a problem hiding this comment.
| root_path: Optional[str] = None, | |
| user_groups: Optional[set[str]] = None): | |
| root_path: str | None] = None, | |
| user_groups: set[str] | None = None): |
|
|
||
| def get_file_info(self, path: Optional[str] = None, current_user: str = None, | ||
| def get_file_info(self, path: Optional[str] = None, current_user: Optional[str] = None, | ||
| fsps: Optional[list] = None) -> FileInfo: |
There was a problem hiding this comment.
| fsps: Optional[list] = None) -> FileInfo: | |
| fsps: list | None = None) -> FileInfo: |
|
|
||
|
|
||
| def yield_file_infos_paginated(self, path: Optional[str] = None, current_user: str = None, | ||
| def yield_file_infos_paginated(self, path: Optional[str] = None, current_user: Optional[str] = None, |
There was a problem hiding this comment.
| def yield_file_infos_paginated(self, path: Optional[str] = None, current_user: Optional[str] = None, | |
| def yield_file_infos_paginated(self, path: sre | None = None, current_user: str | None = None, |
| return file_infos, has_more, next_cursor, total_count, is_truncated | ||
|
|
||
| def yield_file_infos(self, path: Optional[str] = None, current_user: str = None, | ||
| def yield_file_infos(self, path: Optional[str] = None, current_user: Optional[str] = None, |
There was a problem hiding this comment.
| def yield_file_infos(self, path: Optional[str] = None, current_user: Optional[str] = None, | |
| def yield_file_infos(self, path: str | None = None, current_user: str | None = None, |
|
|
||
|
|
||
| def stream_file_contents(self, path: str = None, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: | ||
| def stream_file_contents(self, path: Optional[str] = None, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: |
There was a problem hiding this comment.
| def stream_file_contents(self, path: Optional[str] = None, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: | |
| def stream_file_contents(self, path: str | None = None, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: |
| yield chunk | ||
|
|
||
| def stream_file_range(self, path: str = None, start: int = 0, end: int = 0, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: | ||
| def stream_file_range(self, path: Optional[str] = None, start: int = 0, end: int = 0, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: |
There was a problem hiding this comment.
| def stream_file_range(self, path: Optional[str] = None, start: int = 0, end: int = 0, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: | |
| def stream_file_range(self, path: str | None = None, start: int = 0, end: int = 0, buffer_size: int = DEFAULT_BUFFER_SIZE, file_handle = None) -> Generator[bytes, None, None]: |
In order to review the changes on the
apps-improvementsbranch in my IDE, I wanted things to pass error-check in Pyright. This turned into a large refactor, including a new cross-platform locking layer that cleans up some of the code while expanding its capabilities.With the new file locking layer, we don't need to do stuff like this:
On the other hand, there is a lot of churn.
Summary
fileglancer/platform_compat.py) for optional POSIX-only behavior and portalocker-backed file locking.pyrightconfig.jsonand apixi run pyright-checktask, configured withpythonPlatform: "All"so Linux/macOS/Windows are all validated in one run.The cross-platform compatibility layer
platform_compat.pyis the single place that touches platform-only modules (pwd,grp,fcntl/portalocker, Windowsctypes). It exposes:FileLock/FileLockUnavailable— a non-blocking exclusive file lock backed byportalocker, replacing the rawfcntl.flockcalls in the job poll loop. Same semantics (one worker wins the poll election per interval), no moretry: import fcntlguards.effective_user_home,named_user_home,user_home,username_for_uid,groupname_for_gid,group_names_for_user,current_uid/effective_uid/current_gid,group_ids_for_user. These wrap thepwd/grp/os.get*idcalls and degrade gracefully on non-POSIX.process_is_alive(pid)— a portable process-liveness probe (see the bug fix below).filestore.py,sshkeys.py,user_worker.py,worker_pool.py, andapps/core.pywere updated to route through these helpers instead of importingpwd/grp/fcntldirectly.Behavioral fix: local-executor job liveness on Windows
This is the one change that is not just typing.
_poll_local_jobsusedos.kill(pid, 0)as a liveness probe. On Windowsos.killroutes throughTerminateProcess, so it would kill the job it was polling, and it doesn't raiseProcessLookupErrorfor a dead PID.process_is_alive()now keeps theos.kill(pid, 0)probe on POSIX and uses a non-destructiveOpenProcess(SYNCHRONIZE)+WaitForSingleObjectcheck on Windows.This bug was dormant because
tests/test_poll.pywas skipped wholesale on Windows via a module-levelpytest.importorskip("fcntl"). Removing thefcntldependency (nowFileLock) un-skipped the file and surfaced it.SQLAlchemy 2.0 typed ORM
database.pymigrates the models from the legacydeclarative_base()+Column(...)style to the 2.0 typed style: aDeclarativeBasesubclass withMapped[...]annotations andmapped_column(...). This is what makes the models type-check cleanly; column nullability is now reflected in the types (Mapped[Optional[str]]etc.).Smaller semantic changes bundled in
These came out of making the code provably correct for the type checker, and are slight behavior changes worth a look during review:
Nonewhensession_secret_keyis unset, instead of hashingNone.created/updatedrather than passingNoneinto the requireddatetimefields (the old path could raise at model validation).400validation for the JIRA ticket endpoint body; normalize proxied-path URLs withrstrip("/")+HttpUrlto avoid double slashes; rename the secondget_external_bucketshandler toget_external_buckets_by_fsp(resolves a shadowed function name); tighten a few response-model return annotations.jira_browse_urlwhenatlassian_urlis set.Tests
tests/test_poll.py— dropped the module-levelimportorskip("fcntl"), reworked the lock tests onto the sharedFileLockabstraction, and added aspawnfallback whereforkis unavailable, so the poll suite runs on Windows.tests/test_filestore.py—TestGetUserGroupsnow patchesplatform_compat.optional_moduleinstead of the removedfilestore.pwd/filestore.grpglobals (same assertions).tests/test_worker.py— usesplatform_compat.current_uid.@StephanPreibisch @JaneliaSciComp/fileglancer