# -*- coding: utf-8 -*- """Which job's knowledge layer a HOST-SIDE tool should read, with no doc open. Inside Revit, `designspec.root_dir(doc)` finds a job's `_ticketqc` folder by `doc.Title` -- WILLTOOLS_JOBS wins when it is set, APPDATA is the fallback for a model with no path yet. That lookup needs a live document and cannot be ported here: a terminal running `tools/snapshot_cycle.py` or `tools/knowledge_drift.py` has no `doc.Title` to pick a folder by, and guessing one from several candidates is a worse failure than asking, because a wrong guess runs silently on the wrong job's settings and looks completely normal -- the exact failure mode this whole codebase is built against (see CLAUDE.md's "could not judge" discipline). THE RESOLUTION ORDER, deliberately, and in this order only: 1. an EXPLICIT file (a `--knowledge` flag, or an env var that names a FILE) wins outright. The caller already knows which file; nothing here second-guesses it, and a bad path is reported the ordinary way a missing file is reported (by whoever tries to read it), not specially here. 2. WILLTOOLS_JOBS names a folder holding EXACTLY ONE job subfolder (one that has a `knowledge.yaml` in it) -- unambiguous, so it is used with no further input. 3. a NAMED job (`--job "sample_job"`) picks one out of several by name. 4. otherwise: REFUSE, and name every candidate folder found. This is the correct behaviour when several real job folders exist and none was named -- not a hole to fill in later. Silently picking "the newest one" or "the one that happens to set the key being asked for" is a guess wearing the clothes of a rule, and the day it guesses wrong the run looks completely normal while reading another job's tolerances. This is intentionally the only place this order is written down. Two tools reading job knowledge off disk had two different half-answers to "where is it" before this module existed -- `tools/snapshot_cycle.py` required an explicit `--knowledge` and reported the resulting absence as if the setting itself were missing, and `tools/knowledge_drift.py` walked a path hardcoded to this checkout and ignored WILLTOOLS_JOBS entirely. Both import this now. `plantpath.py` is this file's sibling for the PLANT layer -- one FILE, chosen by an env var that may name either a file or its folder. This is a different shape (WILLTOOLS_JOBS names a folder that may hold several jobs, and there is no `doc.Title` here to break the tie) so it earns its own module rather than being bent to fit plantpath's shape, but the discipline is the same one: never fall back to a plausible-looking wrong answer in silence, and say which of "not configured", "not found" and "found but ambiguous" happened, because they are different facts and read as different sentences. IronPython 2.7: no f-strings, no pathlib. (This module has no Revit import at module scope -- or at all -- so it is also safe to load from host CPython the same way `plantpath.py` is: `tools/snapshot_cycle.py` and `tools/knowledge_drift.py` both do, via the `vibe` package stub documented in `WillTools.extension/tests/conftest.py`.) """ # pylint: disable=invalid-name import os JOBS_ENV = 'WILLTOOLS_JOBS' # same spelling as designspec.JOBS_ENV KNOWLEDGE_FILENAME = 'knowledge.yaml' class JobLocateResult(object): """What `resolve_job_knowledge()` found, or precisely why it did not. `path` is set only on success -- callers should treat `bool(result)` (or `result.path`) as the found/not-found test. `blocking` marks the not-found cases that are a genuine misconfiguration worth stopping a run over (an ambiguous WILLTOOLS_JOBS, a `--job` that names nothing, a WILLTOOLS_JOBS that is not even a folder) as opposed to the ordinary "nothing configured yet" case, which a caller may reasonably continue past on DEFAULTS alone -- exactly as it always could when no `--knowledge` was given. """ __slots__ = ('path', 'how', 'reason', 'candidates', 'root', 'blocking') def __init__(self, path=None, how=None, reason=None, candidates=None, root=None, blocking=False): self.path = path self.how = how self.reason = reason self.candidates = list(candidates or []) self.root = root self.blocking = bool(blocking) def __bool__(self): return self.path is not None __nonzero__ = __bool__ # IronPython 2.7 def __repr__(self): if self.path: return 'JobLocateResult(path={0!r}, how={1!r})'.format( self.path, self.how) return 'JobLocateResult(reason={0!r})'.format(self.reason) def job_candidates(jobs_root): """Every subfolder of `jobs_root` that carries a `knowledge.yaml`. Sorted, so a refusal that lists them is stable across runs and a "single unambiguous" check does not depend on directory-walk order. Nothing here tries to tell a "real" job folder from a stray one (a leftover detach copy, an old bootstrap) -- that would be exactly the judgement call this module exists to refuse to make silently. Every folder with a `knowledge.yaml` in it is a candidate; a human names the right one. """ out = [] if not jobs_root or not os.path.isdir(jobs_root): return out for name in sorted(os.listdir(jobs_root)): folder = os.path.join(jobs_root, name) if not os.path.isdir(folder): continue candidate = os.path.join(folder, KNOWLEDGE_FILENAME) if os.path.isfile(candidate): out.append(candidate) return out def _job_name_of(knowledge_path): return os.path.basename(os.path.dirname(knowledge_path)) def resolve_job_knowledge(explicit=None, jobs_root=None, job_name=None, env=None): """Which job knowledge.yaml a host-side tool should read. A JobLocateResult. explicit a path stated outright -- a `--knowledge` flag, or an env var that already names a FILE (`WILLTOOLS_JOB_KNOWLEDGE`, as `tools/snapshot_cycle.py`'s argparse default already reads). Wins over everything below. Not checked for existence here: whoever reads the file reports a missing one the ordinary way, and duplicating that check here would be a second, possibly-drifted answer to "does this file exist". jobs_root WILLTOOLS_JOBS, already resolved by the caller. Pass `None` to have it read from `env` here instead. job_name a folder name under `jobs_root`, stated explicitly (a `--job` flag) to pick one out of several. env `os.environ` by default; overridable so tests never touch the real environment. """ env = os.environ if env is None else env if explicit: return JobLocateResult(path=explicit, how='explicit') root = jobs_root if jobs_root is not None else env.get(JOBS_ENV) if not root: return JobLocateResult( reason='no job knowledge file was named (--knowledge) and {0} ' 'is not set, so there is no folder to look for a job ' 'under.'.format(JOBS_ENV)) if not os.path.isdir(root): return JobLocateResult( root=root, blocking=True, reason='{0} is set to {1}, but that is not a folder.' .format(JOBS_ENV, root)) if job_name: candidate = os.path.join(root, job_name, KNOWLEDGE_FILENAME) if os.path.isfile(candidate): return JobLocateResult(path=candidate, how='named', root=root) candidates = job_candidates(root) names = [_job_name_of(c) for c in candidates] return JobLocateResult( root=root, candidates=candidates, blocking=True, reason='no job named {0!r} under {1} (looked for {2}). Job ' 'folders found there: {3}' .format(job_name, root, candidate, ', '.join(names) or '(none)')) candidates = job_candidates(root) if len(candidates) == 1: return JobLocateResult(path=candidates[0], how='single', root=root) if not candidates: return JobLocateResult( root=root, reason='{0} is set to {1}, but no job folder under it has a ' '{2}.'.format(JOBS_ENV, root, KNOWLEDGE_FILENAME)) names = [_job_name_of(c) for c in candidates] return JobLocateResult( root=root, candidates=candidates, blocking=True, reason='{0} is {1}, which holds {2} job folders ({3}) -- there is ' 'no doc.Title here to pick one by, so nothing here guesses. ' 'Say which with --job, or point --knowledge at the file ' 'directly.'.format(JOBS_ENV, root, len(candidates), ', '.join(names)))