diff --git a/src/nytid/cli/hr.nw b/src/nytid/cli/hr.nw index 7ddcb3d..c19ea98 100644 --- a/src/nytid/cli/hr.nw +++ b/src/nytid/cli/hr.nw @@ -11,12 +11,19 @@ In this chapter we introduce the subommands found under [[nytid signupsheets]], it's the [[cli.signupsheets]] module. + +Importing [[arrow]], [[ics.icalendar]], [[nytid.signup.hr]], and +[[nytid.signup.sheets]] at module level drags in a chain of heavy +dependencies --- [[ics]], [[requests]], and the full signup machinery --- +on every [[nytid]] invocation. +These are only needed by subcommands that actually process bookings, +so we defer them to the function bodies where they are first needed. +[[ics.icalendar]] and [[nytid.schedules]] are not used in this module at +all; they are simply removed. <<[[hr.py]]>>= -import arrow import csv import datetime from enum import Enum -import ics.icalendar import json import logging import pathlib @@ -26,16 +33,11 @@ import typer import typerconf as config from typing_extensions import Annotated -from nytid.signup import hr -from nytid.signup import sheets import operator from nytid.cli import courses as coursescli from nytid.cli.signupsheets import SIGNUPSHEET_URL_PATH from nytid import courses as courseutils -from nytid import schedules as schedutils -from nytid.signup import hr -from nytid.signup import sheets <> <> @@ -95,7 +97,6 @@ We'll also use their [[CLI]] configs to get the credentials. So if the user has set up the [[ladok]] and [[canvaslms]] commands, we can use the configs there. <>= -import appdirs <> <> @ @@ -349,6 +350,7 @@ def users(<>, for user in hr.hours_per_TA(booked): <> <>= +<> booked = [] for (course, register), config in courses.items(): <> @@ -430,7 +432,7 @@ except KeyError as err: continue <> booked += sheets.read_signup_sheet_from_url(url) -<>= +<>= from nytid.signup import sheets @ @@ -475,7 +477,7 @@ if course_summary: <>= def to_hours(td): return td.total_seconds()/60/60 -<>= +<>= from nytid.signup import hr @ @@ -929,6 +931,7 @@ push_start_opt = typer.Option(help="Push the dates of the contract so that it " formats=["%Y-%m-%d"]) <>= if push_start: + import arrow push_start = arrow.Arrow(push_start.year, push_start.month, push_start.day) start, end = push_forward(start, end, push_start) <>= @@ -1795,6 +1798,8 @@ When we recompute the amanuensis data, we want to set the low percentage and minimum days to zero so that those settings don't interfere. Maybe those settings have changed, but then we still have a contract. <>= +from nytid.signup import hr +from nytid.signup import sheets amanuensis = hr.compute_amanuensis_data(booked, begin_date=start, end_date=end, @@ -2395,6 +2400,13 @@ To generate the time sheet for a TA we need to do the following: \item look up the TA in Canvas and LADOK to get the personnummer; \item put the data in the Excel format. \end{enumerate} + +The [[timesheet]] module wraps [[openpyxl]] and carries several megabytes +of indirect dependencies. +Importing it at module level would slow down every [[nytid]] invocation, +even commands that never touch Excel output. +We therefore defer the import to the body of this chunk, so the cost is +paid only when a user actually requests [[--excel]] output. <>= if not added_events and not removed_events: logging.warning(f"No events for {user}, skipping.") @@ -2411,6 +2423,7 @@ except AttributeError as err: <> +from nytid.signup.hr import timesheet timesheet.make_xlsx(personnummer, name, f"{user}@kth.se" if not "@" in user else user, @@ -2423,8 +2436,6 @@ timesheet.make_xlsx(personnummer, course_leader_signature=course_responsible_signature \ if sign else None, output=output_filename) -<>= -from nytid.signup.hr import timesheet <>= course_responsible: Annotated[str, course_responsible_opt] = default_course_responsible, @@ -2594,6 +2605,8 @@ def summarize_user(user, course_events, - `course_events` is a list of events. - Optional `salary` is the hourly salary. """ + import arrow + from nytid.signup import sheets, hr <<[[summarize_user]] variables>> <> <> diff --git a/src/nytid/cli/schedule.nw b/src/nytid/cli/schedule.nw index 6e64e41..e269cb5 100644 --- a/src/nytid/cli/schedule.nw +++ b/src/nytid/cli/schedule.nw @@ -4,12 +4,17 @@ In this chapter we introduce the subcommands found under [[nytid schedule]], it's the [[cli.schedule]] module. + +Importing [[arrow]], [[ics]], [[dateutil]], [[nytid.signup.sheets]], and +[[nytid.schedules]] at module level costs several hundred milliseconds on +every [[nytid]] invocation --- even for unrelated commands like +[[nytid track status]]. +These imports are only required by the [[ics]] and [[show]] commands, so +we defer them to the function bodies of those commands and to the +[[schedule_todo_events]] helper where [[arrow]] and [[ics.event]] are used. <<[[schedule.py]]>>= import datetime from enum import Enum -import arrow as arrowlib -import dateutil.tz -import ics.event import logging import typer import typerconf as config @@ -18,10 +23,8 @@ from typing_extensions import Annotated from nytid.cli import courses as coursescli from nytid import courses as courseutils -from nytid import schedules as schedutils -from nytid.cli.signupsheets import SIGNUPSHEET_URL_PATH -<> +<> <> cli = typer.Typer(name="schedule", @@ -83,6 +86,7 @@ def ics_cmd(<> = ".*", <> print(schedule.serialize()) <>= +<> <> schedule = ics.icalendar.Calendar() @@ -122,8 +126,10 @@ sign-up sheet and read the bookings. url = config.get(SIGNUPSHEET_URL_PATH) <> booked += sheets.read_signup_sheet_from_url(url) -<>= +<>= from nytid.signup import sheets +from nytid import schedules as schedutils +from nytid.cli.signupsheets import SIGNUPSHEET_URL_PATH @ If it's a Google Sheets sharing URL, we want to convert it to the export-CSV @@ -156,7 +162,7 @@ except KeyError: username_opt = typer.Option(help="Username to filter sign-up sheet for, " "defaults to logged in user's username.") -<>= +<>= import os @ @@ -170,7 +176,7 @@ if user: booked = map(functools.partial(add_reserve_to_title, user), booked) schedule.events.update(set(map_drop_exceptions(sheets.EventFromCSV, booked))) -<>= +<>= import functools import ics.icalendar <>= @@ -196,6 +202,7 @@ def add_reserve_to_title(ta, event): Ouput: the same CSV data, but with title prepended "RESERVE: " if TA is among the reserves. """ + from nytid.signup import sheets _, reserves = sheets.get_booked_TAs_from_csv(event) if ta in reserves: event[0] = "RESERVE: " + event[0] @@ -272,7 +279,7 @@ schedule.extra += [ContentLine("REFRESH-INTERVAL", <>= schedule.method = "PUBLISH" schedule.extra += [ContentLine("X-PUBLISHED-TTL", {}, "PT20M")] -<>= +<>= from ics.grammar.parse import ContentLine @ @@ -536,9 +543,9 @@ def local_naive(dt): We also need the reverse: stamping a naive local datetime with the system timezone so the [[ics]] library does not default to UTC\@. -<>= -local_tz = dateutil.tz.tzlocal() -@ +Because [[dateutil.tz]] is a deferred import (loaded only when +[[schedule_todo_events]] runs), [[local_tz]] is computed inside that +function rather than at module level. The function must handle both timezone-aware and naive datetimes, since ICS events may arrive in either form. @@ -565,6 +572,7 @@ def test_todo_event_preserves_local_time(): """Naive local datetimes must survive ICS round-trip.""" import arrow as arrowlib import dateutil.tz + import ics.event naive_dt = datetime.datetime(2026, 3, 2, 12, 0) event = ics.event.Event() event.begin = arrowlib.get(naive_dt, dateutil.tz.tzlocal()) @@ -882,6 +890,7 @@ def schedule_todo_events(user, start, end, occupied): """ Returns VEVENTs for scheduled todos. """ + <> <> if not todos: return [] @@ -901,6 +910,22 @@ def schedule_todo_events(user, start, end, occupied): return scheduled_events @ +[[schedule_todo_events]] uses [[ics.event]], [[arrow]], and [[dateutil.tz]] +to build calendar events from the computed free slots. +These are deferred to inside the function so that loading the +[[schedule]] module at CLI startup does not pull in the full calendar +stack just because the [[external]] subcommands or lightweight commands +are invoked. +We also compute [[local_tz]] here for the same reason: it requires +[[dateutil.tz]], and module-level evaluation would force the import at +startup. +<>= +import arrow as arrowlib +import dateutil.tz +import ics.event +local_tz = dateutil.tz.tzlocal() +@ + We import [[todo]] here rather than at the top of the file to avoid a circular import: [[todo]] imports [[schedule]] for scheduling its items, and [[schedule]] needs [[todo]] only for this helper. @@ -1129,7 +1154,7 @@ for event in events: if output: dest.close() typer.echo(f"Wrote schedule to {output}") -<>= +<>= import csv import sys @ diff --git a/src/nytid/cli/signupsheets.nw b/src/nytid/cli/signupsheets.nw index 12108be..fed8901 100644 --- a/src/nytid/cli/signupsheets.nw +++ b/src/nytid/cli/signupsheets.nw @@ -4,10 +4,15 @@ In this chapter we introduce the subommands found under [[nytid signupsheets]], it's the [[cli.signupsheets]] module. + +Importing [[nytid.signup.sheets]] triggers a chain of heavy imports --- +[[ics]], [[requests]], and the full signup machinery --- that adds hundreds +of milliseconds even when the user just runs [[nytid track status]]. +Because [[sheets]] is only needed by the [[generate]] command, we defer it +to that command's function body rather than importing it at module level. <<[[signupsheets.py]]>>= import datetime from enum import Enum -import ics.icalendar import logging import pathlib import sys @@ -16,11 +21,7 @@ from typing_extensions import Annotated from nytid.cli import courses as coursescli from nytid import courses as courseutils -from nytid import schedules as schedutils -from nytid.signup import hr -from nytid.signup import sheets -<> <> cli = typer.Typer(name="signupsheets", @@ -92,6 +93,7 @@ We have that [[<>]] and Then we can get the list of courses and their ICS URLs. We then iterate through them and generate a sign-up sheet for each. <>= +<> <> <> @@ -106,7 +108,7 @@ for (course, register), course_conf in courses.items(): url = course_conf.get("ics") sheets.generate_signup_sheet(outfile, url, needed_TAs) <> -<>= +<>= from nytid.signup import sheets from nytid.signup import utils @ @@ -195,7 +197,7 @@ if edit: os.startfile(outfile) else: subprocess.call(["xdg-open", outfile]) -<>= +<>= import os, platform, subprocess @ diff --git a/src/nytid/cli/track.nw b/src/nytid/cli/track.nw index 968e56e..0036d88 100644 --- a/src/nytid/cli/track.nw +++ b/src/nytid/cli/track.nw @@ -137,6 +137,7 @@ The main module structure follows nytid's standard patterns: <<[[track.py]]>>= import datetime from enum import Enum +import functools import json import logging import os @@ -206,6 +207,14 @@ We use nytid's StorageRoot system for managing tracking data files, which provid a consistent interface with other nytid components and supports different storage backends (local filesystem, AFS, etc.). +[[get_tracking_dir]] is called on every subcommand that touches a file — +[[status]], [[start]], [[stop]], etc.\ all need the directory path. +Each call without caching re-reads the configuration store; with dozens +of callers inside a single invocation this adds up. +The config value is stable for the lifetime of a process, so caching with +[[functools.lru_cache]] is safe and reduces the repeated config look-ups +to a single one per process. + <>= # Configuration keys for track system TRACKING_DIR_CONFIG = "track.data_dir" @@ -229,8 +238,9 @@ def get_tracking_storage() -> storage.StorageRoot: return storage.StorageRoot(tracking_dir) +@functools.lru_cache(maxsize=None) def get_tracking_dir() -> pathlib.Path: - """Get the tracking data directory path (for compatibility)""" + """Get the tracking data directory path (cached for performance).""" return get_tracking_storage()._StorageRoot__path def get_tracking_data_file() -> pathlib.Path: @@ -2289,6 +2299,12 @@ When no [[--who]] is specified, we discover all active workers by globbing for session files in the tracking directory. This gives a quick overview of who is currently tracking time. +Each [[load_active_session(worker)]] call would re-derive the session +file path through [[get_current_session_file]] → [[get_tracking_dir]]. +In the loop we already hold the [[pathlib.Path]] from the glob, so we +read it directly to avoid that redundant path computation for each +worker file we found. + <>= tracking_dir = get_tracking_dir() found_any = False @@ -2300,7 +2316,14 @@ if tracking_dir.exists(): worker = session_file.stem.replace( "current_session_", "" ) - session = load_active_session(worker) + try: + with open(session_file, 'r') as _f: + session = ActiveSession.from_dict(json.load(_f)) + except (json.JSONDecodeError, KeyError, ValueError) as _e: + logging.warning( + f"Error loading session {session_file}: {_e}" + ) + session = ActiveSession() if session.is_active(): found_any = True typer.echo(f"Currently tracking ({worker}):")