Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions src/nytid/cli/hr.nw
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

<<imports>>
<<constants>>
Expand Down Expand Up @@ -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.
<<imports>>=
import appdirs
<<import and set up Canvas>>
<<import and set up ladok3>>
@
Expand Down Expand Up @@ -349,6 +350,7 @@ def users(<<argument for matching courses>>,
for user in hr.hours_per_TA(booked):
<<print detailed or non-detailed data about [[user]]>>
<<let [[booked]] be all bookings from [[courses]]>>=
<<import hr command dependencies>>
booked = []
for (course, register), config in courses.items():
<<add to [[booked]] from [[course]] in [[register]] using [[config]]>>
Expand Down Expand Up @@ -430,7 +432,7 @@ except KeyError as err:
continue
<<convert [[url]] to Google Sheet CSV-export URL if necessary>>
booked += sheets.read_signup_sheet_from_url(url)
<<imports>>=
<<import hr command dependencies>>=
from nytid.signup import sheets
@

Expand Down Expand Up @@ -475,7 +477,7 @@ if course_summary:
<<helper functions>>=
def to_hours(td):
return td.total_seconds()/60/60
<<imports>>=
<<import hr command dependencies>>=
from nytid.signup import hr
@

Expand Down Expand Up @@ -929,6 +931,7 @@ push_start_opt = typer.Option(help="Push the dates of the contract so that it "
formats=["%Y-%m-%d"])
<<modify [[start]], [[end]] or dates in [[data]] if necessary>>=
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)
<<helper functions>>=
Expand Down Expand Up @@ -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.
<<let [[amanuesis]] be the recomputed data from [[booked]] between [[start]] and [[end]]>>=
from nytid.signup import hr
from nytid.signup import sheets
amanuensis = hr.compute_amanuensis_data(booked,
begin_date=start,
end_date=end,
Expand Down Expand Up @@ -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.
<<generate [[user]]'s timesheet from [[added_events]], [[removed_events]]>>=
if not added_events and not removed_events:
logging.warning(f"No events for {user}, skipping.")
Expand All @@ -2411,6 +2423,7 @@ except AttributeError as err:

<<let [[output_filename]] be the filename for the time sheet>>

from nytid.signup.hr import timesheet
timesheet.make_xlsx(personnummer,
name,
f"{user}@kth.se" if not "@" in user else user,
Expand All @@ -2423,8 +2436,6 @@ timesheet.make_xlsx(personnummer,
course_leader_signature=course_responsible_signature \
if sign else None,
output=output_filename)
<<imports>>=
from nytid.signup.hr import timesheet
<<options for time sheet data>>=
course_responsible: Annotated[str, course_responsible_opt]
= default_course_responsible,
Expand Down Expand Up @@ -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>>
<<let [[hours]] be the hours of [[user]]>>
<<let [[events]] be a list of events for [[user]], sorted by date>>
Expand Down
53 changes: 39 additions & 14 deletions src/nytid/cli/schedule.nw
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

<<imports>>
<<module-level imports>>
<<constants>>

cli = typer.Typer(name="schedule",
Expand Down Expand Up @@ -83,6 +86,7 @@ def ics_cmd(<<argument for matching courses>> = ".*",
<<generate [[schedule]] from sign-up sheets for matching courses>>
print(schedule.serialize())
<<generate [[schedule]] from sign-up sheets for matching courses>>=
<<import schedule command dependencies>>
<<set list [[courses]] to ((course, register), config)-pairs>>

schedule = ics.icalendar.Calendar()
Expand Down Expand Up @@ -122,8 +126,10 @@ sign-up sheet and read the bookings.
url = config.get(SIGNUPSHEET_URL_PATH)
<<convert [[url]] to Google Sheet CSV-export URL if necessary>>
booked += sheets.read_signup_sheet_from_url(url)
<<imports>>=
<<import schedule command dependencies>>=
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
Expand Down Expand Up @@ -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.")
<<imports>>=
<<module-level imports>>=
import os
@

Expand All @@ -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)))
<<imports>>=
<<import schedule command dependencies>>=
import functools
import ics.icalendar
<<helper functions>>=
Expand All @@ -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]
Expand Down Expand Up @@ -272,7 +279,7 @@ schedule.extra += [ContentLine("REFRESH-INTERVAL",
<<set refresh rate for [[schedule]], same as TimeEdit>>=
schedule.method = "PUBLISH"
schedule.extra += [ContentLine("X-PUBLISHED-TTL", {}, "PT20M")]
<<imports>>=
<<import schedule command dependencies>>=
from ics.grammar.parse import ContentLine
@

Expand Down Expand Up @@ -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\@.
<<constants>>=
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.
Expand All @@ -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())
Expand Down Expand Up @@ -882,6 +890,7 @@ def schedule_todo_events(user, start, end, occupied):
"""
Returns VEVENTs for scheduled todos.
"""
<<import todo event dependencies>>
<<load and filter todos>>
if not todos:
return []
Expand All @@ -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 todo event dependencies>>=
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.
Expand Down Expand Up @@ -1129,7 +1154,7 @@ for event in events:
if output:
dest.close()
typer.echo(f"Wrote schedule to {output}")
<<imports>>=
<<module-level imports>>=
import csv
import sys
@
Expand Down
16 changes: 9 additions & 7 deletions src/nytid/cli/signupsheets.nw
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

<<imports>>
<<constants>>

cli = typer.Typer(name="signupsheets",
Expand Down Expand Up @@ -92,6 +93,7 @@ We have that [[<<argument for matching courses>>]] 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.
<<generate sign-up sheet>>=
<<import generate command dependencies>>
<<set list [[courses]] to ((course, register), config)-pairs>>

<<generation iteration variables>>
Expand All @@ -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)
<<open [[outfile]] for editing if requested>>
<<imports>>=
<<import generate command dependencies>>=
from nytid.signup import sheets
from nytid.signup import utils
@
Expand Down Expand Up @@ -195,7 +197,7 @@ if edit:
os.startfile(outfile)
else:
subprocess.call(["xdg-open", outfile])
<<imports>>=
<<import generate command dependencies>>=
import os, platform, subprocess
@

Expand Down
27 changes: 25 additions & 2 deletions src/nytid/cli/track.nw
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 management>>=
# Configuration keys for track system
TRACKING_DIR_CONFIG = "track.data_dir"
Expand All @@ -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:
Expand Down Expand Up @@ -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.

<<show all workers status>>=
tracking_dir = get_tracking_dir()
found_any = False
Expand All @@ -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}):")
Expand Down