sharepoint-knowledge 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sharepoint_knowledge-0.1.0/PKG-INFO +40 -0
- sharepoint_knowledge-0.1.0/README.md +18 -0
- sharepoint_knowledge-0.1.0/pyproject.toml +39 -0
- sharepoint_knowledge-0.1.0/setup.cfg +4 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge/__init__.py +3 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge/auth_sp.py +118 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge/cli_sp.py +191 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge/client_sp.py +69 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge/crawl_sp.py +277 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge/server_sp.py +334 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge/settings_sp.py +80 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge.egg-info/PKG-INFO +40 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge.egg-info/SOURCES.txt +19 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge.egg-info/dependency_links.txt +1 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge.egg-info/entry_points.txt +2 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge.egg-info/requires.txt +15 -0
- sharepoint_knowledge-0.1.0/src/sharepoint_knowledge.egg-info/top_level.txt +1 -0
- sharepoint_knowledge-0.1.0/tests/test_agent_doc_consistency.py +29 -0
- sharepoint_knowledge-0.1.0/tests/test_client_sp.py +96 -0
- sharepoint_knowledge-0.1.0/tests/test_crawl_sp.py +94 -0
- sharepoint_knowledge-0.1.0/tests/test_server_flow_sp.py +78 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sharepoint-knowledge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SharePoint connector for the grounded-Q&A engine — site pages + document libraries, Entra device-code sign-in, local models, cited answers
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/nkunal1992/agents
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: confluence-knowledge[files]>=0.4.1
|
|
10
|
+
Requires-Dist: msal>=1.31
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: mcp>=1.8
|
|
13
|
+
Provides-Extra: models
|
|
14
|
+
Requires-Dist: confluence-knowledge[models]>=0.4.1; extra == "models"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
18
|
+
Requires-Dist: python-docx>=1.1; extra == "dev"
|
|
19
|
+
Requires-Dist: python-pptx>=1.0; extra == "dev"
|
|
20
|
+
Requires-Dist: openpyxl>=3.1; extra == "dev"
|
|
21
|
+
Requires-Dist: pypdf>=5.0; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# sharepoint-knowledge
|
|
24
|
+
|
|
25
|
+
SharePoint connector on the shared grounded-Q&A engine — Site Pages +
|
|
26
|
+
document libraries (docx/pdf/pptx/xlsx/csv/txt/md/html), Entra device-code
|
|
27
|
+
sign-in, local models, cited answers. Same engine as
|
|
28
|
+
[`confluence-knowledge`](../confluence-knowledge) and
|
|
29
|
+
[`local-knowledge`](../local-knowledge).
|
|
30
|
+
|
|
31
|
+
- `sharepoint_knowledge/auth_sp.py` — MSAL device-code flow, token cache in
|
|
32
|
+
the OS keyring (protected-file fallback), silent refresh
|
|
33
|
+
- `sharepoint_knowledge/client_sp.py` — GET-only Graph client; downloads go
|
|
34
|
+
through `@microsoft.graph.downloadUrl` WITHOUT the Bearer header
|
|
35
|
+
- `sharepoint_knowledge/crawl_sp.py` — durable BFS site → pages + libraries
|
|
36
|
+
- `sharepoint_knowledge/server_sp.py` — the `sharepoint-knowledge/*` tools
|
|
37
|
+
- `sharepoint_knowledge/cli_sp.py` — setup/doctor/prepare-models/register/
|
|
38
|
+
update/serve/clean
|
|
39
|
+
|
|
40
|
+
See `docs/sharepoint-knowledge.md` for the full guide.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# sharepoint-knowledge
|
|
2
|
+
|
|
3
|
+
SharePoint connector on the shared grounded-Q&A engine — Site Pages +
|
|
4
|
+
document libraries (docx/pdf/pptx/xlsx/csv/txt/md/html), Entra device-code
|
|
5
|
+
sign-in, local models, cited answers. Same engine as
|
|
6
|
+
[`confluence-knowledge`](../confluence-knowledge) and
|
|
7
|
+
[`local-knowledge`](../local-knowledge).
|
|
8
|
+
|
|
9
|
+
- `sharepoint_knowledge/auth_sp.py` — MSAL device-code flow, token cache in
|
|
10
|
+
the OS keyring (protected-file fallback), silent refresh
|
|
11
|
+
- `sharepoint_knowledge/client_sp.py` — GET-only Graph client; downloads go
|
|
12
|
+
through `@microsoft.graph.downloadUrl` WITHOUT the Bearer header
|
|
13
|
+
- `sharepoint_knowledge/crawl_sp.py` — durable BFS site → pages + libraries
|
|
14
|
+
- `sharepoint_knowledge/server_sp.py` — the `sharepoint-knowledge/*` tools
|
|
15
|
+
- `sharepoint_knowledge/cli_sp.py` — setup/doctor/prepare-models/register/
|
|
16
|
+
update/serve/clean
|
|
17
|
+
|
|
18
|
+
See `docs/sharepoint-knowledge.md` for the full guide.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sharepoint-knowledge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SharePoint connector for the grounded-Q&A engine — site pages + document libraries, Entra device-code sign-in, local models, cited answers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
dependencies = [
|
|
13
|
+
"confluence-knowledge[files]>=0.4.1",
|
|
14
|
+
"msal>=1.31",
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
"mcp>=1.8",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
models = [
|
|
21
|
+
"confluence-knowledge[models]>=0.4.1",
|
|
22
|
+
]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.0",
|
|
25
|
+
"ruff>=0.5",
|
|
26
|
+
"python-docx>=1.1",
|
|
27
|
+
"python-pptx>=1.0",
|
|
28
|
+
"openpyxl>=3.1",
|
|
29
|
+
"pypdf>=5.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
sharepoint-knowledge = "sharepoint_knowledge.cli_sp:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/nkunal1992/agents"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Entra auth for sharepoint-knowledge — device-code flow + silent refresh.
|
|
2
|
+
|
|
3
|
+
The MSAL token cache is stored as a credential blob through the shared
|
|
4
|
+
keyring/file machinery (same posture as the Confluence PAT). The access
|
|
5
|
+
token itself never enters chat, logs, or command-line arguments.
|
|
6
|
+
|
|
7
|
+
SP_KNOWLEDGE_TOKEN env var short-circuits everything (tests/CI).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from confluence_knowledge.auth import get_credential, store_credential
|
|
17
|
+
from confluence_knowledge.settings import SetupError
|
|
18
|
+
|
|
19
|
+
SCOPES = ["https://graph.microsoft.com/Sites.Read.All", "offline_access"]
|
|
20
|
+
ENV_TOKEN = "SP_KNOWLEDGE_TOKEN"
|
|
21
|
+
AUTH_FILE = ".sp_auth.json"
|
|
22
|
+
INDEX_DIR = ".sp_knowledge_index"
|
|
23
|
+
SERVICE = "sharepoint-knowledge"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cache_ref(profile) -> str:
|
|
27
|
+
return f"cache:{profile.credential_ref}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_msal():
|
|
31
|
+
try:
|
|
32
|
+
import msal
|
|
33
|
+
except ImportError as e:
|
|
34
|
+
raise SetupError(
|
|
35
|
+
"msal isn't installed — reinstall with `pip install "
|
|
36
|
+
"sharepoint-knowledge[models]`") from e
|
|
37
|
+
return msal
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_cache(workdir: Path, profile):
|
|
41
|
+
msal = _load_msal()
|
|
42
|
+
blob = get_credential(workdir, _cache_ref(profile), env=ENV_TOKEN,
|
|
43
|
+
service=SERVICE, auth_file=AUTH_FILE,
|
|
44
|
+
home_dir=".sharepoint-knowledge")
|
|
45
|
+
if blob:
|
|
46
|
+
try:
|
|
47
|
+
cache = msal.SerializableTokenCache()
|
|
48
|
+
cache.deserialize(json.loads(blob))
|
|
49
|
+
return cache
|
|
50
|
+
except Exception: # noqa: BLE001 — corrupt cache → re-sign-in
|
|
51
|
+
return None
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_cache(workdir: Path, home_dir: Path, profile, cache) -> str:
|
|
56
|
+
return store_credential(workdir, home_dir, _cache_ref(profile),
|
|
57
|
+
json.dumps(cache.serialize()),
|
|
58
|
+
service=SERVICE, auth_file=AUTH_FILE,
|
|
59
|
+
index_dir=INDEX_DIR)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _app(profile, cache=None):
|
|
63
|
+
msal = _load_msal()
|
|
64
|
+
kwargs = {"token_cache": cache} if cache else {}
|
|
65
|
+
return msal.PublicClientApplication(
|
|
66
|
+
profile.client_id,
|
|
67
|
+
authority=f"{_get_auth_base()}/{profile.tenant_id}",
|
|
68
|
+
**kwargs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_auth_base() -> str:
|
|
72
|
+
"""Authority base override for e2e (fake token endpoint)."""
|
|
73
|
+
return os.environ.get("SP_KNOWLEDGE_AUTH_BASE",
|
|
74
|
+
"https://login.microsoftonline.com")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def acquire_token(profile, workdir: Path, home_dir: Path) -> str:
|
|
78
|
+
"""Silent first (cached refresh token); device flow only on expiry —
|
|
79
|
+
so normal use needs no re-sign-in after setup."""
|
|
80
|
+
if os.environ.get(ENV_TOKEN):
|
|
81
|
+
return os.environ[ENV_TOKEN]
|
|
82
|
+
cache = _load_cache(workdir, profile)
|
|
83
|
+
app = _app(profile, cache)
|
|
84
|
+
accounts = app.get_accounts()
|
|
85
|
+
if accounts:
|
|
86
|
+
r = app.acquire_token_silent(SCOPES, account=accounts[0])
|
|
87
|
+
if r and "access_token" in r:
|
|
88
|
+
if cache and cache.has_state_changed:
|
|
89
|
+
_save_cache(workdir, home_dir, profile, cache)
|
|
90
|
+
return r["access_token"]
|
|
91
|
+
raise SetupError(
|
|
92
|
+
"SharePoint sign-in expired — run `sharepoint-knowledge setup` to "
|
|
93
|
+
"re-authenticate (device code, one browser step)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def device_sign_in(profile, workdir: Path, home_dir: Path) -> dict:
|
|
97
|
+
"""Interactive device-code sign-in — used by `setup`. Prints the
|
|
98
|
+
message Microsoft returns (URL + code); blocks until approved or it
|
|
99
|
+
times out. Returns the stored-cache location description."""
|
|
100
|
+
msal = _load_msal()
|
|
101
|
+
cache = msal.SerializableTokenCache()
|
|
102
|
+
app = _app(profile, cache)
|
|
103
|
+
flow = app.initiate_device_flow(scopes=SCOPES)
|
|
104
|
+
if "user_code" not in flow:
|
|
105
|
+
raise SetupError(
|
|
106
|
+
"couldn't start device sign-in: "
|
|
107
|
+
f"{flow.get('error_description', flow)}")
|
|
108
|
+
print("\n" + flow["message"] + "\n") # "go to microsoft.com/devicelogin…"
|
|
109
|
+
r = app.acquire_token_by_device_flow(flow)
|
|
110
|
+
if "access_token" not in r:
|
|
111
|
+
raise SetupError(
|
|
112
|
+
"sign-in failed: "
|
|
113
|
+
f"{r.get('error_description', r.get('error', r))}")
|
|
114
|
+
where = _save_cache(workdir, home_dir, profile, cache)
|
|
115
|
+
account = r.get("id_token_claims", {})
|
|
116
|
+
return {"where": where,
|
|
117
|
+
"user": (account.get("preferred_username")
|
|
118
|
+
or account.get("email") or "signed in")}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""sharepoint-knowledge CLI — setup (Entra device sign-in), doctor,
|
|
2
|
+
prepare-models, register, update, serve, clean.
|
|
3
|
+
|
|
4
|
+
Same shape as the other knowledge CLIs. `setup` prints the exact Entra
|
|
5
|
+
app-registration steps for IT, collects tenant/client/site, then runs the
|
|
6
|
+
one-time device-code sign-in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from confluence_knowledge.registration import register as _register
|
|
18
|
+
from confluence_knowledge.retrieval import prepare_models
|
|
19
|
+
from confluence_knowledge.settings import SetupError, ensure_gitignore
|
|
20
|
+
|
|
21
|
+
from .settings_sp import (
|
|
22
|
+
APP_NAME,
|
|
23
|
+
AUTH_ENV,
|
|
24
|
+
AUTH_FILE_NAME,
|
|
25
|
+
INDEX_DIR_NAME,
|
|
26
|
+
PROFILE_NAME,
|
|
27
|
+
SPProfile,
|
|
28
|
+
home,
|
|
29
|
+
workdir,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
TOKEN_STEPS = """\
|
|
33
|
+
Entra app registration — once per tenant, done by IT (or you, if allowed):
|
|
34
|
+
1. Entra portal (entra.microsoft.com) → Applications → App registrations
|
|
35
|
+
→ New registration
|
|
36
|
+
2. Name: e.g. "sharepoint-knowledge-agent" — Accounts in this org only
|
|
37
|
+
3. Authentication → Enable "Allow public client flows" → YES (required
|
|
38
|
+
for device-code sign-in — no client secret is used or needed)
|
|
39
|
+
4. API permissions → Add → Microsoft Graph → Delegated → Sites.Read.All
|
|
40
|
+
(+ offline_access is implied by device flow)
|
|
41
|
+
5. Grant admin consent for the tenant (the delegated scope normally
|
|
42
|
+
requires it) — one click on the Permissions page
|
|
43
|
+
6. Copy: Directory (tenant) ID and Application (client) ID — needed below
|
|
44
|
+
|
|
45
|
+
This app requests only delegated read access — it can read exactly what
|
|
46
|
+
the signed-in user can already read, nothing more.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _setup() -> int:
|
|
51
|
+
print("SharePoint Knowledge — first-run setup\n")
|
|
52
|
+
print(TOKEN_STEPS)
|
|
53
|
+
|
|
54
|
+
tenant = input("Tenant ID (GUID): ").strip()
|
|
55
|
+
client = input("Client ID (application ID, GUID): ").strip()
|
|
56
|
+
site = input("SharePoint site URL (e.g. https://contoso.sharepoint.com"
|
|
57
|
+
"/sites/team): ").strip().rstrip("/")
|
|
58
|
+
if not (tenant and client and site):
|
|
59
|
+
print("tenant ID, client ID, and site URL are all required",
|
|
60
|
+
file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
profile = SPProfile(tenant_id=tenant, client_id=client,
|
|
64
|
+
site_url=site, credential_ref="sp-token")
|
|
65
|
+
profile.save()
|
|
66
|
+
print(f"\nprofile saved to {home() / PROFILE_NAME}\n")
|
|
67
|
+
|
|
68
|
+
print("Sign-in (device code — one browser step). The code you get "
|
|
69
|
+
"never leaves the OS credential store.", flush=True)
|
|
70
|
+
if os.environ.get(AUTH_ENV):
|
|
71
|
+
print("SP_KNOWLEDGE_TOKEN is set — using it; skipping sign-in")
|
|
72
|
+
else:
|
|
73
|
+
from .auth_sp import device_sign_in
|
|
74
|
+
try:
|
|
75
|
+
r = device_sign_in(profile, workdir(), home())
|
|
76
|
+
print(f"signed in as {r['user']} — token cache stored at "
|
|
77
|
+
f"{r['where']}")
|
|
78
|
+
except SetupError as e:
|
|
79
|
+
print(f"sign-in failed: {e}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
# make sure the workdir's gitignore hides the index + auth file
|
|
83
|
+
workdir().mkdir(parents=True, exist_ok=True)
|
|
84
|
+
ensure_gitignore(workdir(), [AUTH_FILE_NAME,
|
|
85
|
+
f"{INDEX_DIR_NAME}/"])
|
|
86
|
+
|
|
87
|
+
if input("\nDownload the two local models now (~175 MB, one-time)? "
|
|
88
|
+
"[Y/n] ").strip().lower() not in ("n", "no"):
|
|
89
|
+
for line in prepare_models(home()):
|
|
90
|
+
print(line)
|
|
91
|
+
|
|
92
|
+
print("\nsetup complete — run `sharepoint-knowledge doctor` to verify")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _doctor() -> int:
|
|
97
|
+
from confluence_knowledge.update import check
|
|
98
|
+
|
|
99
|
+
from .server_sp import status
|
|
100
|
+
s = status()
|
|
101
|
+
print(json.dumps(s, indent=2))
|
|
102
|
+
upd = check("sharepoint-knowledge")
|
|
103
|
+
if upd["installed"]:
|
|
104
|
+
line = f"version: {upd['installed']}"
|
|
105
|
+
if upd["latest"]:
|
|
106
|
+
line += f" (latest: {upd['latest']})"
|
|
107
|
+
elif upd["latest"] is None:
|
|
108
|
+
line += " (latest: unchecked — offline or internal mirror)"
|
|
109
|
+
print(line)
|
|
110
|
+
if upd["update_available"]:
|
|
111
|
+
print("update available: run `sharepoint-knowledge update` "
|
|
112
|
+
"(or pip install -U sharepoint-knowledge[models])")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
p = argparse.ArgumentParser(prog="sharepoint-knowledge",
|
|
118
|
+
description="Local SharePoint indexer + "
|
|
119
|
+
"grounded-Q&A MCP service")
|
|
120
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
121
|
+
sub.add_parser("setup", help="configure Entra sign-in + site")
|
|
122
|
+
sub.add_parser("doctor", help="report configuration + model readiness")
|
|
123
|
+
sub.add_parser("prepare-models", help="download the pinned local models")
|
|
124
|
+
sub.add_parser("update", help="upgrade this package via pip -U")
|
|
125
|
+
reg = sub.add_parser("register",
|
|
126
|
+
help="write the IDE MCP config automatically")
|
|
127
|
+
reg.add_argument("--ide", choices=["auto", "all", "jetbrains", "vscode"],
|
|
128
|
+
default="auto")
|
|
129
|
+
reg.add_argument("--workspace", default=None,
|
|
130
|
+
help="repo dir for the workspace .vscode/mcp.json")
|
|
131
|
+
reg.add_argument("--remove", action="store_true",
|
|
132
|
+
help="remove our server entry instead of adding it")
|
|
133
|
+
sub.add_parser("serve", help="run the stdio MCP server")
|
|
134
|
+
sub.add_parser("clean", help="delete all indexed data")
|
|
135
|
+
|
|
136
|
+
args = p.parse_args()
|
|
137
|
+
if args.cmd == "setup":
|
|
138
|
+
sys.exit(_setup())
|
|
139
|
+
if args.cmd == "doctor":
|
|
140
|
+
sys.exit(_doctor())
|
|
141
|
+
if args.cmd == "prepare-models":
|
|
142
|
+
for line in prepare_models(home()):
|
|
143
|
+
print(line)
|
|
144
|
+
return
|
|
145
|
+
if args.cmd == "update":
|
|
146
|
+
import subprocess
|
|
147
|
+
cmd = [sys.executable, "-m", "pip", "install", "-U",
|
|
148
|
+
"sharepoint-knowledge[models]"]
|
|
149
|
+
if os.name == "nt":
|
|
150
|
+
print("on Windows, upgrade via the venv python directly:\n "
|
|
151
|
+
+ " ".join(f'"{c}"' if " " in c else c for c in cmd))
|
|
152
|
+
return
|
|
153
|
+
r = subprocess.run(cmd, check=False)
|
|
154
|
+
if r.returncode == 0:
|
|
155
|
+
print("\nupdated — models are pinned and unchanged; "
|
|
156
|
+
"run `sharepoint-knowledge doctor` to confirm")
|
|
157
|
+
sys.exit(r.returncode)
|
|
158
|
+
if args.cmd == "register":
|
|
159
|
+
results = _register(
|
|
160
|
+
ide=args.ide,
|
|
161
|
+
workspace=Path(args.workspace) if args.workspace else None,
|
|
162
|
+
remove=args.remove,
|
|
163
|
+
server_id=APP_NAME,
|
|
164
|
+
module="sharepoint_knowledge.server_sp",
|
|
165
|
+
env_var="SP_KNOWLEDGE_WORKDIR")
|
|
166
|
+
for path, action in results:
|
|
167
|
+
print(f"{action}: {path}")
|
|
168
|
+
if args.remove:
|
|
169
|
+
print("removed — reload the IDE to drop the server")
|
|
170
|
+
else:
|
|
171
|
+
print("registered — reload the IDE so the tools appear")
|
|
172
|
+
return
|
|
173
|
+
if args.cmd == "serve":
|
|
174
|
+
from .server_sp import main as serve
|
|
175
|
+
serve()
|
|
176
|
+
return
|
|
177
|
+
if args.cmd == "clean":
|
|
178
|
+
import shutil
|
|
179
|
+
|
|
180
|
+
from .settings_sp import index_root
|
|
181
|
+
d = index_root()
|
|
182
|
+
if d.exists():
|
|
183
|
+
shutil.rmtree(d)
|
|
184
|
+
print(f"deleted {d}")
|
|
185
|
+
else:
|
|
186
|
+
print("nothing to clean")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""GET-only Microsoft Graph client for sharepoint-knowledge.
|
|
2
|
+
|
|
3
|
+
Reuses the allowlist + GET-only machinery of the Confluence client: every
|
|
4
|
+
request passes through the same `_validate_path` — the only Graph routes
|
|
5
|
+
allowed are the read ones the crawler needs. File downloads use the
|
|
6
|
+
pre-authenticated `@microsoft.graph.downloadUrl` fetched WITHOUT the Bearer
|
|
7
|
+
header — the token never leaves graph.microsoft.com.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from confluence_knowledge.client import (
|
|
17
|
+
AccessError,
|
|
18
|
+
ConfluenceClient,
|
|
19
|
+
EndpointRule,
|
|
20
|
+
SecurityException,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
GRAPH_RULES = [
|
|
24
|
+
EndpointRule(re.compile(r"sites/[^/]+:.*"), "site by hostname:path"),
|
|
25
|
+
EndpointRule(re.compile(r"sites/[^/]+$"), "site info"),
|
|
26
|
+
EndpointRule(re.compile(r"sites/[^/]+/lists/?$"), "site lists"),
|
|
27
|
+
EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/?$"), "list items"),
|
|
28
|
+
EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/[^/]+/?$"), "list item"),
|
|
29
|
+
EndpointRule(re.compile(r"sites/[^/]+/pages/?$"), "site pages"),
|
|
30
|
+
EndpointRule(re.compile(r"sites/[^/]+/pages/[^/]+/?$"), "site page"),
|
|
31
|
+
EndpointRule(re.compile(r"sites/[^/]+/drives/?$"), "document libraries"),
|
|
32
|
+
EndpointRule(re.compile(r"sites/[^/]+/drive/?$"), "default drive"),
|
|
33
|
+
EndpointRule(re.compile(r"sites/[^/]+/drive/root:.+"), "item by path"),
|
|
34
|
+
EndpointRule(re.compile(r"drives/[^/]+/root/children/?$"), "library root"),
|
|
35
|
+
EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/children/?$"), "folder children"),
|
|
36
|
+
EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/?$"), "item metadata"),
|
|
37
|
+
EndpointRule(re.compile(r"me/?$"), "identity"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_client(base_url: str, token: str) -> ConfluenceClient:
|
|
42
|
+
"""Graph client — Bearer stays on the API host only."""
|
|
43
|
+
return ConfluenceClient(base_url, {"Authorization": f"Bearer {token}"},
|
|
44
|
+
GRAPH_RULES)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def download_file(url: str, max_bytes: int = 25 * 1024 * 1024) -> bytes:
|
|
48
|
+
"""Fetch a pre-authenticated downloadUrl — NO Authorization header.
|
|
49
|
+
|
|
50
|
+
The URL embeds a short-lived download token; sending our Bearer would
|
|
51
|
+
leak it to a different host (and is unnecessary — these links are
|
|
52
|
+
self-authenticating). https only (localhost allowed for tests).
|
|
53
|
+
"""
|
|
54
|
+
u = urlparse(url)
|
|
55
|
+
if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
|
|
56
|
+
raise SecurityException(
|
|
57
|
+
"download URL isn't https — refusing to fetch")
|
|
58
|
+
with httpx.Client(timeout=60.0, follow_redirects=True) as http, \
|
|
59
|
+
http.stream("GET", url) as r:
|
|
60
|
+
# bounded stream — don't buffer an unbounded response
|
|
61
|
+
if r.status_code >= 400:
|
|
62
|
+
raise AccessError(f"download failed: HTTP {r.status_code}")
|
|
63
|
+
out = bytearray()
|
|
64
|
+
for chunk in r.iter_bytes(1 << 20):
|
|
65
|
+
out += chunk
|
|
66
|
+
if len(out) > max_bytes:
|
|
67
|
+
raise AccessError(
|
|
68
|
+
f"file exceeds {max_bytes} bytes — refusing to buffer")
|
|
69
|
+
return bytes(out)
|