scripts/dataroom_search.py — Data Room Toolkit
From version 0.1.5 of Data Room Toolkit, shown publicly by its creator. 17.8 KiB · SHA-256 d35506e6957d90d155e79802222d3f355c3c52d07ea3e177c720173a8eac7dd5.
#!/usr/bin/env python3
# Data Room Toolkit 0.1.5
# By Zero Width Lab (zerowidthlab.com)
"""Inventory and optionally rank files in a SharePoint document library."""
from __future__ import annotations
import argparse
import json
import math
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator
from urllib.parse import quote
from graph_client import (
ConfigError,
CredentialError,
GraphClientError,
acquire_token,
collection_page,
check_collection_url,
write_new_text,
LocalOutputError,
rooted_remote_path,
validate_remote_path,
display_text,
encode_path_segments,
load_config,
request,
resolve_drive_id,
)
__author__ = "Zero Width Lab"
def say(text: object = "", *, error: bool = False) -> None:
"""Print one human-readable line with untrusted text escaped by display_text."""
print(display_text(text), file=sys.stderr if error else sys.stdout)
GRAPH_ROOT = "https://graph.microsoft.com/v1.0"
REDACTED_USER = "[redacted]"
class GraphSession:
def __init__(self, token: str, timeout: float, max_retries: int, throttle: float):
self.token = token
self.timeout = timeout
self.max_retries = max_retries
self.throttle = throttle
def request(self, method: str, url: str) -> tuple[int, Any, dict[str, str]]:
return request(
method,
url,
token=self.token,
timeout=self.timeout,
max_retries=self.max_retries,
throttle=self.throttle,
expect_json=True,
)
def non_negative_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be an integer >= 0") from exc
if parsed < 0:
raise argparse.ArgumentTypeError("must be an integer >= 0")
return parsed
def non_negative_float(value: str) -> float:
try:
parsed = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be a finite number >= 0") from exc
if not math.isfinite(parsed) or parsed < 0:
raise argparse.ArgumentTypeError("must be a finite number >= 0")
return parsed
def parse_since(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise argparse.ArgumentTypeError(
"must be an ISO 8601 date or date-time"
) from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def parse_keywords(values: list[str] | None) -> list[str]:
keywords: list[str] = []
for value in values or []:
for keyword in value.split(","):
clean = keyword.strip()
if clean and clean.casefold() not in {item.casefold() for item in keywords}:
keywords.append(clean)
return keywords
def modifier_name(item: dict[str, Any], redact: bool) -> str | None:
user = (item.get("lastModifiedBy") or {}).get("user") or {}
name = user.get("displayName")
if not name:
return None
return REDACTED_USER if redact else str(name)
def combine_remote_path(*parts: str | None) -> str:
return "/".join(validate_remote_path(part) for part in parts if part)
def request_json(
client: GraphSession, method: str, url: str
) -> tuple[int, dict[str, Any], dict[str, str]]:
status, payload, headers = client.request(method, url)
return status, payload if isinstance(payload, dict) else {}, headers
def iter_collection(client: GraphSession, url: str) -> Iterator[dict[str, Any]]:
next_url: str | None = url
visited: set[str] = set()
while next_url:
check_collection_url(next_url, visited)
status, payload, _ = client.request("GET", next_url)
if not 200 <= status < 300:
raise RuntimeError(f"Graph request failed with HTTP {status}")
items, link = collection_page(payload, url)
for item in items:
yield item
next_url = link
def initial_item_url(drive_id: str, root_path: str) -> str:
root_path = validate_remote_path(root_path, allow_empty=True)
drive = quote(drive_id, safe="")
selection = "?$select=id,name"
if root_path:
return (
f"{GRAPH_ROOT}/drives/{drive}/root:/"
f"{encode_path_segments(root_path)}:{selection}"
)
return f"{GRAPH_ROOT}/drives/{drive}/root{selection}"
def walk_files(
client: GraphSession,
drive_id: str,
root_path: str,
max_depth: int | None,
throttle: float,
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
status, root, _ = request_json(client, "GET", initial_item_url(drive_id, root_path))
if status != 200 or not root.get("id"):
raise RuntimeError(f"cannot read the selected library root, HTTP {status}")
drive = quote(drive_id, safe="")
stack: list[tuple[str, int]] = [(root_path, 0)]
files: list[dict[str, Any]] = []
skipped: list[dict[str, str]] = []
while stack:
parent_path, depth = stack.pop()
route = f"root:/{encode_path_segments(parent_path)}:" if parent_path else "root"
item_url = (
f"{GRAPH_ROOT}/drives/{drive}/{route}/children"
"?$top=200&$select=id,name,size,folder,file,lastModifiedDateTime,"
"lastModifiedBy,parentReference"
)
try:
for item in iter_collection(client, item_url):
try:
name = validate_remote_path(item.get("name"))
except ConfigError as exc:
raise ConfigError(f"item {ascii(item.get('name'))}: {exc}") from exc
if "/" in name:
raise ConfigError("remote item name must be one path segment")
path = combine_remote_path(parent_path, name)
if "folder" in item:
if max_depth is None or depth < max_depth:
child_id = item.get("id")
if child_id:
stack.append((path, depth + 1))
elif "file" in item:
files.append(
{
"name": name,
"path": path,
"size": int(item.get("size") or 0),
"modified": item.get("lastModifiedDateTime"),
"modifier": item.get("lastModifiedBy"),
}
)
except RuntimeError as exc:
skipped.append({"path": parent_path, "error": str(exc)})
say(
f"warning: skipping folder '{parent_path or '(root)'}': {exc}",
error=True,
)
continue
return files, skipped
def keyword_rank(path: str, keywords: list[str]) -> tuple[int, list[str]]:
folded = path.casefold()
matched = [keyword for keyword in keywords if keyword.casefold() in folded]
score = sum(folded.count(keyword.casefold()) for keyword in matched)
return score, matched
def content_search(
client: GraphSession,
drive_id: str,
root_path: str,
keywords: list[str],
redact_users: bool,
skipped: list[dict[str, str]] | None = None,
outside_root: list[str] | None = None,
) -> list[dict[str, Any]]:
root_path = validate_remote_path(root_path, allow_empty=True)
results: list[dict[str, Any]] = []
seen: set[str] = set()
drive = quote(drive_id, safe="")
search_base = (
f"root:/{encode_path_segments(root_path)}:/search"
if root_path
else "root/search"
)
for keyword in keywords:
encoded_keyword = quote(keyword, safe="")
url = (
f"{GRAPH_ROOT}/drives/{drive}/{search_base}(q='{encoded_keyword}')"
"?$top=100&$select=id,name,size,lastModifiedDateTime,lastModifiedBy,"
"parentReference"
)
for item in iter_collection(client, url):
key = str(item.get("id") or item.get("webUrl") or item.get("name"))
if key in seen:
continue
seen.add(key)
try:
details = item
parent = (details.get("parentReference") or {}).get("path")
if not parent:
item_id = item.get("id")
if not isinstance(item_id, str) or not item_id:
raise ConfigError("missing item id for path lookup")
validate_remote_path(item_id)
status, details, _ = request_json(client, "GET",
f"{GRAPH_ROOT}/drives/{drive}/items/{quote(item_id, safe='')}"
"?$select=parentReference,name")
if status != 200:
raise ConfigError(f"path lookup failed with HTTP {status}")
parent = (details.get("parentReference") or {}).get("path")
if not isinstance(parent, str) or "root:" not in parent:
raise ConfigError("parent path cannot be resolved")
parent = parent.split("root:", 1)[1]
if parent.startswith("/"):
parent = parent[1:]
parent = validate_remote_path(parent, allow_empty=True)
name = validate_remote_path(details.get("name"))
if "/" in name:
raise ConfigError("remote item name must be one path segment")
path = combine_remote_path(parent, name)
if root_path and not path.startswith(root_path + "/"):
if outside_root is not None:
outside_root.append(path)
continue
except (RuntimeError, ValueError, TypeError, AttributeError) as exc:
reason = {"item": ascii(item.get("id") or item.get("name")), "error": str(exc)}
if skipped is not None:
skipped.append(reason)
say(f"warning: skipping content-search hit {reason['item']}: {exc}", error=True)
continue
results.append(
{
"name": item.get("name"),
"path": path,
"size": int(item.get("size") or 0),
"modified": item.get("lastModifiedDateTime"),
"modifier": modifier_name(item, redact_users),
"matched_keyword": keyword,
}
)
return results
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inventory files in a SharePoint document library.",
epilog="By Zero Width Lab (zerowidthlab.com)",
)
parser.add_argument("--drive-id")
parser.add_argument("--site-id")
parser.add_argument("--root-path")
parser.add_argument("--config")
parser.add_argument(
"--keywords",
action="append",
help="Repeat the flag or provide comma-separated terms.",
)
parser.add_argument("--content-search", action="store_true",
help="Print content matches in text or JSON output.")
recent = parser.add_mutually_exclusive_group()
recent.add_argument("--recent-days", type=non_negative_int)
recent.add_argument("--since", type=parse_since)
parser.add_argument("--max-depth", type=non_negative_int)
parser.add_argument("--out", type=Path)
parser.add_argument("--json", action="store_true")
parser.add_argument("--redact-users", action="store_true")
parser.add_argument(
"--allow-partial",
action="store_true",
help=(
"exit 0 even when folders were skipped; the output still reports "
"incomplete: true and the skipped folders (default: exit 2)"
),
)
parser.add_argument("--max-retries", type=non_negative_int)
parser.add_argument("--throttle", type=non_negative_float)
return parser
def merged_setting(
cli_value: Any, config: dict[str, Any], key: str, default: Any = None
) -> Any:
return cli_value if cli_value is not None else config.get(key, default)
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
keywords = parse_keywords(args.keywords)
if args.content_search and any("'" in keyword for keyword in keywords):
parser.error("--keywords cannot contain a single quote with --content-search")
if args.content_search and not keywords:
parser.error("--content-search requires at least one --keywords value")
try:
overrides = {
key: value
for key, value in {
"drive_id": args.drive_id,
"site_id": args.site_id,
"root_path": args.root_path,
"max_retries": args.max_retries,
"throttle_seconds": args.throttle,
}.items()
if value is not None
}
config = load_config(args.config, overrides=overrides, require_target=True)
max_retries = int(config.get("max_retries", 4))
throttle = float(config.get("throttle_seconds", 0.0))
timeout = float(config.get("request_timeout_seconds", 60.0))
token = acquire_token(
timeout=timeout, max_retries=max_retries, throttle=throttle
)
client = GraphSession(token, timeout, max_retries, throttle)
drive_id = resolve_drive_id(
token,
drive_id=config.get("drive_id"),
site_id=config.get("site_id"),
timeout=timeout,
max_retries=max_retries,
throttle=throttle,
)
root_path = config["root_path"]
files, skipped_folders = walk_files(
client, str(drive_id), root_path, args.max_depth, throttle
)
since = args.since
if since is None:
recent_days = 120 if args.recent_days is None else args.recent_days
since = datetime.now(timezone.utc) - timedelta(days=recent_days)
inventory: list[dict[str, Any]] = []
for file_item in files:
modified_raw = file_item.get("modified")
try:
modified = parse_since(str(modified_raw))
except argparse.ArgumentTypeError:
modified = None
if modified is not None and modified < since:
continue
score, matches = keyword_rank(str(file_item["path"]), keywords)
inventory.append(
{
"name": file_item["name"],
"path": file_item["path"],
"size": file_item["size"],
"modified": modified_raw,
"modifier": modifier_name(
{"lastModifiedBy": file_item.get("modifier")},
args.redact_users,
),
**({"rank": score, "keyword_matches": matches} if keywords else {}),
}
)
if keywords:
inventory.sort(key=lambda item: (-int(item["rank"]), str(item["path"])))
else:
inventory.sort(key=lambda item: str(item["path"]))
skipped_hits: list[dict[str, str]] = []
outside_root: list[str] = []
report = {
"drive": str(drive_id)[:8] + "...",
"root_path": root_path,
"file_count": len(inventory),
"files": inventory,
"content_search": (
content_search(
client,
str(drive_id),
root_path,
keywords,
args.redact_users,
skipped_hits,
outside_root,
)
if args.content_search
else []
),
}
if skipped_folders:
report["incomplete"] = True
report["skipped_folders"] = skipped_folders
report["outside_root"] = len(outside_root)
report["skipped_content_hits"] = skipped_hits
report["skipped_content_hit_count"] = len(skipped_hits)
if skipped_hits:
report["incomplete"] = True
rendered = json.dumps(report, indent=2, ensure_ascii=False)
if args.out:
write_new_text(args.out, rendered + "\n")
if args.json:
print(rendered)
else:
for item in inventory:
rank = f" rank={item['rank']}" if "rank" in item else ""
modifier = item.get("modifier") or "unknown"
say(
f"{item['modified']} {item['size']} {item['path']} "
f"modifier={modifier}{rank}"
)
for hit in report["content_search"]:
say(f"Content match: {hit['path']} keyword={hit['matched_keyword']}")
if outside_root:
say(f"Content hits outside root: {len(outside_root)}")
if skipped_hits:
say(f"Content hits skipped: {len(skipped_hits)} (see stderr).")
say(f"Files: {len(inventory)}")
if skipped_folders:
say(
"Incomplete inventory: "
f"{len(skipped_folders)} folder(s) skipped "
"(see stderr for details)."
)
return 0 if not (skipped_folders or skipped_hits) or args.allow_partial else 2
except (CredentialError, ConfigError) as exc:
say(f"error: {exc}", error=True)
return 1
except LocalOutputError as exc:
say(f"error: local output failed: {exc}", error=True)
return 1
except GraphClientError as exc:
status = f" with HTTP {exc.status}" if exc.status is not None else ""
say(f"error: Microsoft Graph request failed{status}: {exc}", error=True)
return 1
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
say(f"error: {exc}", error=True)
return 1
if __name__ == "__main__":
raise SystemExit(main())
License: MIT (read it).
MIT
MIT LicenseCopyright (c) 2026 Zero Width Lab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.