exegete 0.14.1a0.dev1__py3-none-any.whl

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.
@@ -0,0 +1,174 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ """The server's settings, read under both spellings (v0.14.1).
3
+
4
+ Every setting this server reads from its environment is read here and
5
+ nowhere else (tests/test_v0141_compat.py forbids any other environment
6
+ read in the package). Each has a new spelling, starting EXEGETE_, and
7
+ the earlier one, which is still read until v1.0 (names.SETTINGS). The
8
+ one other read here is not a setting: Windows' own folder, SystemRoot,
9
+ from which the transition check starts PowerShell by its full path.
10
+
11
+ The rule when both spellings are set:
12
+
13
+ - only the new one: used, silently;
14
+ - only the earlier one: used, and the server's start-up log says once
15
+ which spelling to use instead, and until when the earlier one works;
16
+ - both, saying the same thing: used, silently;
17
+ - both, saying different things: the server does not start, and says
18
+ which two spellings disagree (never their values);
19
+ - a workspace is required if either spelling of
20
+ EXEGETE_WORKSPACE_REQUIRED says 1.
21
+
22
+ "The same thing" is judged after each value is tidied exactly as its
23
+ read tidies it (spaces stripped where the read strips them, the tool set
24
+ in lower case, a workspace path with `~` expanded, the two switches
25
+ compared as "is it 1", the AI coder name after validate_coder_name), and
26
+ a blank value counts as unset. The value then used is the raw text of
27
+ the spelling chosen, so it behaves exactly as it did before the rename.
28
+ """
29
+
30
+ import os
31
+ from pathlib import Path
32
+ from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional
33
+
34
+ from . import names
35
+
36
+
37
+ class Reading(NamedTuple):
38
+ """One setting as read: the raw value (None when neither spelling is
39
+ set), the spelling it came from (the new one when neither is set, so
40
+ a message has a name to give), whether only the earlier spelling was
41
+ set, and whether the two spellings disagree."""
42
+ value: Optional[str]
43
+ name: str
44
+ old_only: bool
45
+ conflict: bool
46
+
47
+
48
+ def _blank(value: Optional[str]) -> bool:
49
+ return value is None or not value.strip()
50
+
51
+
52
+ def _toolset(value: str) -> Any:
53
+ return value.strip().lower()
54
+
55
+
56
+ def _coder_name(value: str) -> Any:
57
+ from .database import validate_coder_name # no import cycle
58
+ try:
59
+ return validate_coder_name(value, "name")
60
+ except ValueError:
61
+ return ("not a valid coder name", value.strip())
62
+
63
+
64
+ def _path(value: str) -> Any:
65
+ try:
66
+ return str(Path(value).expanduser())
67
+ except (RuntimeError, ValueError):
68
+ return ("not a path", value)
69
+
70
+
71
+ def _required_switch(value: str) -> Any:
72
+ return value.strip() == "1"
73
+
74
+
75
+ def _exact_switch(value: str) -> Any:
76
+ return value == "1"
77
+
78
+
79
+ def _as_given(value: str) -> Any:
80
+ return value
81
+
82
+
83
+ # How each read tidies its value today, which is how the two spellings
84
+ # are compared.
85
+ NORMALISE: Dict[str, Callable[[str], Any]] = {
86
+ "toolset": _toolset,
87
+ "ai_coder_name": _coder_name,
88
+ "workspace": _path,
89
+ "workspace_required": _required_switch,
90
+ "allow_unknown_schema": _exact_switch,
91
+ "project_path": _as_given,
92
+ }
93
+
94
+
95
+ def new_name(key: str) -> str:
96
+ return names.SETTINGS[key][0]
97
+
98
+
99
+ def old_name(key: str) -> str:
100
+ return names.SETTINGS[key][1]
101
+
102
+
103
+ def read(key: str, environ: Optional[Mapping[str, str]] = None) -> Reading:
104
+ """Setting `key` (a key of names.SETTINGS) from `environ`, by default
105
+ this process's environment."""
106
+ env = os.environ if environ is None else environ
107
+ new, old = names.SETTINGS[key]
108
+ new_raw, old_raw = env.get(new), env.get(old)
109
+ if not _blank(new_raw) and not _blank(old_raw):
110
+ normalise = NORMALISE[key]
111
+ new_value, old_value = normalise(new_raw), normalise(old_raw)
112
+ if key == "workspace_required":
113
+ # Required if either says so: the safer reading
114
+ if not new_value and old_value:
115
+ return Reading(old_raw, old, False, False)
116
+ return Reading(new_raw, new, False, False)
117
+ return Reading(new_raw, new, False, new_value != old_value)
118
+ if not _blank(old_raw):
119
+ return Reading(old_raw, old, True, False)
120
+ if not _blank(new_raw):
121
+ return Reading(new_raw, new, False, False)
122
+ # Neither says anything: a blank one is still handed on as it is, so
123
+ # a read that treats a blank value in its own way (a blank coder name
124
+ # stops the server) goes on doing so.
125
+ if new_raw is not None:
126
+ return Reading(new_raw, new, False, False)
127
+ if old_raw is not None:
128
+ return Reading(old_raw, old, False, False)
129
+ return Reading(None, new, False, False)
130
+
131
+
132
+ def value(key: str, environ: Optional[Mapping[str, str]] = None
133
+ ) -> Optional[str]:
134
+ """Only the value of `read(key)`."""
135
+ return read(key, environ).value
136
+
137
+
138
+ def conflicts(environ: Optional[Mapping[str, str]] = None) -> List[str]:
139
+ """The start-up refusal for every setting whose two spellings
140
+ disagree, in words that name the spellings and never the values."""
141
+ found = []
142
+ for key in names.SETTINGS:
143
+ if read(key, environ).conflict:
144
+ new, old = names.SETTINGS[key]
145
+ found.append(
146
+ f"{new} and {old} are both set and say different things. "
147
+ f"They are two spellings of one setting ({old} is the "
148
+ f"earlier one, read until {names.OLD_SPELLINGS_UNTIL}): "
149
+ f"remove one of them from the host's configuration, or "
150
+ f"make them the same.")
151
+ return found
152
+
153
+
154
+ def old_spellings_in_use(environ: Optional[Mapping[str, str]] = None
155
+ ) -> List[str]:
156
+ """One log line for each setting given only under its earlier
157
+ spelling."""
158
+ lines = []
159
+ for key in names.SETTINGS:
160
+ if read(key, environ).old_only:
161
+ new, old = names.SETTINGS[key]
162
+ lines.append(
163
+ f"{old} is the earlier spelling of {new}; it is read "
164
+ f"until {names.OLD_SPELLINGS_UNTIL}. Use {new} in the "
165
+ f"host's configuration instead.")
166
+ return lines
167
+
168
+
169
+ def windows_system_root() -> str:
170
+ """Windows' own folder: SystemRoot, or C:\\Windows when it is unset
171
+ or not a full path (a relative one would be looked up from the
172
+ current folder)."""
173
+ root = os.environ.get("SystemRoot", "")
174
+ return root if os.path.isabs(root) else "C:\\Windows"
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ """Memo privacy: QualCoder's '#####' personal-note convention.
3
+
4
+ QualCoder 4.0 lets researchers keep the tail of any memo private from
5
+ the AI: everything from the first '#####' marker onward is never shown
6
+ to the model, and AI memo updates preserve that private suffix
7
+ verbatim (upstream src/qualcoder/ai_memo.py:28-59 at pin 9bddf17,
8
+ applied to tool results in ai_mcp_server.py:210-223). This server
9
+ honours the same convention on every memo it returns to the client and
10
+ on every memo it writes, so a project touched by both tools keeps the
11
+ same promise to the researcher.
12
+
13
+ PERSONAL_NOTE_MARK, _SEPARATOR_CHARS, split_public_private_memo,
14
+ extract_ai_memo and merge_public_memo are QualCoder's own code, copied
15
+ from ai_memo.py:28-59 at 9bddf17 (author Kai Dröge) statement for
16
+ statement, with local names changed, so that the behaviour is upstream's
17
+ exactly; NOTICE lists them.
18
+ neutralize_marker, rewrite_public_memo and strip_private_memos are this
19
+ project's own. The behaviour the copied functions give:
20
+
21
+ - Split: the FIRST marker wins; the private suffix starts AT the
22
+ marker, marker included. Text with no marker is entirely public.
23
+ A marker at position 0 makes the whole memo private. A marker with
24
+ nothing after it still creates a (marker-only) private suffix.
25
+ - Extract: the public part is returned byte-for-byte, including any
26
+ whitespace that preceded the marker. No annotation is added: the
27
+ strip is silent by owner ruling.
28
+ - Merge (AI writes): the AI-provided text is itself reduced to its
29
+ public part first, so an AI write can never create a private zone,
30
+ and never reads, replaces, or deletes an existing one. An existing
31
+ private suffix is preserved verbatim, re-joined with the whitespace
32
+ run that separated the old public text from the marker. Writing an
33
+ empty public text against a memo with a private suffix leaves just
34
+ the suffix (the row keeps the researcher's private note).
35
+
36
+ The one deliberate exception, ruled by the owner for parity with
37
+ QualCoder's own exports: file exports (REFI-QDA, codebook files,
38
+ report and CSV files) carry FULL memos, marker and suffix included.
39
+ Export tools disclose this in their descriptions; tools that return
40
+ memo content into the AI conversation always strip.
41
+ """
42
+
43
+ import re
44
+ from typing import Any, Callable, Optional, Tuple
45
+
46
+ # The marker QualCoder 4.0 documents for private memo tails
47
+ # (upstream ai_memo.py:28).
48
+ PERSONAL_NOTE_MARK = "#####"
49
+
50
+ # Any run of five or more hashes contains the marker. neutralize_marker
51
+ # collapses the whole run: a plain .replace("#####", "####") would turn
52
+ # "######" into "#####" and re-form the marker it meant to remove.
53
+ _MARKER_RUN_RE = re.compile(r"#{5,}")
54
+
55
+ # Payload keys treated as memo text by strip_private_memos. Journal
56
+ # entries are exposed under 'content' by this server and are stripped
57
+ # at their build site instead ('content' also names file fulltext, so
58
+ # it cannot be a blanket key here).
59
+ _MEMO_KEYS = frozenset({"memo"})
60
+
61
+ # The whitespace characters upstream treats as the public/private
62
+ # separator when re-joining a preserved suffix (ai_memo.py:57).
63
+ _SEPARATOR_CHARS = " \t\r\n"
64
+
65
+
66
+ def split_public_private_memo(memo: Any) -> Tuple[str, str]:
67
+ """Split a memo into (public_text, private_suffix).
68
+
69
+ The private suffix starts at the FIRST marker and includes it;
70
+ it is empty when no marker is present. None is treated as ''.
71
+ """
72
+ text = "" if memo is None else str(memo)
73
+ mark = text.find(PERSONAL_NOTE_MARK)
74
+ if mark < 0:
75
+ return text, ""
76
+ return text[:mark], text[mark:]
77
+
78
+
79
+ def extract_ai_memo(memo: Any) -> str:
80
+ """The part of a memo that may be shown to the AI (public text)."""
81
+ public, _private = split_public_private_memo(memo)
82
+ return public
83
+
84
+
85
+ def neutralize_marker(text: Any) -> str:
86
+ """Make a non-memo string safe to embed in a memo's public zone.
87
+
88
+ Code and category names and owner strings are written verbatim into
89
+ merge provenance blocks. They carry no privacy semantics of their
90
+ own, so a '#####' inside one would otherwise plant a private zone in
91
+ the target memo and hide everything after it (including the merged
92
+ source memo) from every AI read. Every run of five or more hashes is
93
+ collapsed to four; text without such a run is returned unchanged.
94
+ None is treated as ''.
95
+ """
96
+ return _MARKER_RUN_RE.sub("####", "" if text is None else str(text))
97
+
98
+
99
+ def merge_public_memo(existing_memo: Any, new_public_memo: Any) -> str:
100
+ """Replace a memo's public text, preserving any private suffix.
101
+
102
+ The new text is reduced to its own public part first (an AI write
103
+ cannot smuggle a marker in). With no existing suffix this is a
104
+ plain replace; with one, the suffix survives verbatim, joined by
105
+ the whitespace run that preceded the old marker.
106
+ """
107
+ existing_public, private_suffix = split_public_private_memo(existing_memo)
108
+ public = extract_ai_memo(new_public_memo)
109
+ if private_suffix == "":
110
+ return public
111
+ if public == "":
112
+ return private_suffix
113
+ trimmed = existing_public.rstrip(_SEPARATOR_CHARS)
114
+ separator = existing_public[len(trimmed):]
115
+ return public + separator + private_suffix
116
+
117
+
118
+ def rewrite_public_memo(stored: Any,
119
+ rewrite: Callable[[str], str]) -> Optional[str]:
120
+ """Apply `rewrite` to the public part of a note; the private part is
121
+ carried across verbatim and never read.
122
+
123
+ The public part is a PREFIX of the stored text
124
+ (`split_public_private_memo` returns `text[:mark]`), so an offset in
125
+ the public part is an offset in the stored note and the two halves
126
+ concatenate back without a separator. Returns the new stored text,
127
+ or None when the note would not read back with the rewritten public
128
+ part: the rewrite made a marker inside the public part, or across its
129
+ boundary with the private part (a pseudonym ending in a hash, written
130
+ just before the marker, moves the marker earlier; Brief 2 fix round
131
+ 1, QA-B2-3). Writing it would hide part of the researcher's note
132
+ from every later AI read, or move a pseudonym behind the marker, so
133
+ the caller leaves that note as it is and counts it.
134
+
135
+ Not `merge_public_memo`, on purpose. That function re-adds the
136
+ whitespace run that preceded the old marker, and a rewrite that
137
+ substitutes names never touches that run (a surface form cannot
138
+ begin or end with whitespace), so the rewritten public part already
139
+ ends in it: merging would double it. Concatenation gives, byte for
140
+ byte, what `merge_public_memo(stored, new_public.rstrip(" \\t\\r\\n"))`
141
+ gives, and `merge_public_memo` itself stays matched to upstream
142
+ (the pseudonymisation tool's note rewrite, v0.13 Brief 2, 4.3).
143
+ The old public part can never contain a marker (it ends where the
144
+ first one begins), so a marker in the new one was MADE by the
145
+ rewrite: hashes already in the note meeting a pseudonym, or two
146
+ pseudonyms meeting across a character that is not a word character.
147
+ One test covers both places: the new stored text, split again, must
148
+ give back exactly the new public part.
149
+ """
150
+ public, private = split_public_private_memo(stored)
151
+ new_public = rewrite(public)
152
+ new_stored = new_public + private
153
+ if split_public_private_memo(new_stored)[0] != new_public:
154
+ return None
155
+ return new_stored
156
+
157
+
158
+ def strip_private_memos(value: Any) -> Any:
159
+ """Recursively strip private suffixes from a result payload.
160
+
161
+ Walks dicts, lists and tuples; every string under a 'memo' key is
162
+ reduced to its public part (upstream's payload sanitiser contract,
163
+ ai_mcp_server.py:210-223). Everything else passes through
164
+ unchanged. Returns a new structure; the input is not mutated.
165
+ """
166
+ if isinstance(value, dict):
167
+ cleaned = {}
168
+ for key, item in value.items():
169
+ if key in _MEMO_KEYS and isinstance(item, str):
170
+ cleaned[key] = extract_ai_memo(item)
171
+ else:
172
+ cleaned[key] = strip_private_memos(item)
173
+ return cleaned
174
+ if isinstance(value, list):
175
+ return [strip_private_memos(item) for item in value]
176
+ if isinstance(value, tuple):
177
+ return tuple(strip_private_memos(item) for item in value)
178
+ return value
179
+
180
+
181
+ # What every tool that writes the assistant's text into a memo, a note or
182
+ # a journal entry says of that text, in its description (v0.14, the
183
+ # claims audit's item 4).
184
+ MARKER_REFUSED_DESCRIPTION = (
185
+ "Text containing '#####', QualCoder's private-note marker, is refused "
186
+ "before anything is written or backed up: what follows the marker is "
187
+ "the researcher's own, written in QualCoder.")
188
+
189
+
190
+ def private_marker_refusal(text: Any, field: str) -> Optional[str]:
191
+ """The refusal for assistant-supplied text that holds the private-note
192
+ marker, or None when it holds none (v0.14, the claims audit's item 4).
193
+
194
+ QualCoder's own AI server reduces such text to its public part and
195
+ writes that, silently (`merge_public_memo`, from ai_memo.py). Here that
196
+ silence destroyed notes: "##### Researcher note" has an empty public
197
+ part, so update_annotation deleted the annotation, set_memo emptied
198
+ the memo, and a journal entry lost everything after the marker with
199
+ no word said. A researcher who knows QualCoder 4.0's convention and
200
+ asks the assistant to "make this note private" meets exactly that.
201
+ So this server refuses instead, and every tool that writes the
202
+ assistant's text into a memo, a note or an entry checks before its
203
+ backup. The departure from QualCoder is deliberate, for that reason.
204
+
205
+ The refusal does not spell the marker out: this server's answers stay
206
+ free of it, so that a marker in any answer always means a leak.
207
+ """
208
+ if not isinstance(text, str) or PERSONAL_NOTE_MARK not in text:
209
+ return None
210
+ return (f"{field} contains QualCoder's private-note marker (five hash "
211
+ f"signs in a row); nothing was written. What follows that "
212
+ f"marker is the researcher's private note, written in QualCoder "
213
+ f"and never shown to the assistant, so this server does not "
214
+ f"write it: send the text without the marker. A note the "
215
+ f"researcher wants to keep private is written in QualCoder.")
exegete/names.py ADDED
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ """Every name this program goes by, typed once.
3
+
4
+ The project was called qualcoder-mcp until 0.14.0-alpha and is called
5
+ Exegete from 0.14.1-alpha. The code reads its runtime names from here,
6
+ messages included, so that each is typed in one place (a name that is
7
+ also part of a path or an import appears there too: the package folder
8
+ `src/exegete`, and `exegete.server:main` in pyproject.toml).
9
+ """
10
+
11
+ # The PyPI distribution, the command it installs and the Python package.
12
+ DISTRIBUTION = "exegete"
13
+ COMMAND = "exegete"
14
+ PACKAGE = "exegete"
15
+
16
+ # The old names, still answered: the old name's package on PyPI carries
17
+ # the command `qualcoder-mcp`, and a two-file stand-in package
18
+ # `qualcoder_mcp` (src/qualcoder_mcp, and its copy under
19
+ # packaging/pypi-old-name) hands over to exegete.server:main, so that
20
+ # `qualcoder-mcp` and `python -m qualcoder_mcp.server` start Exegete.
21
+ OLD_DISTRIBUTION = "qualcoder-mcp"
22
+ OLD_COMMAND = "qualcoder-mcp"
23
+ OLD_PACKAGE = "qualcoder_mcp"
24
+
25
+ # The server's settings (environment variables), each under its new
26
+ # spelling and the earlier one, which is still read until v1.0: one
27
+ # table, read only through env_settings.read (a test forbids any other
28
+ # environment read in the package). When both spellings are set they
29
+ # must agree, or the server does not start.
30
+ ENV_PREFIX = "EXEGETE_"
31
+ SETTINGS = {
32
+ "toolset": ("EXEGETE_TOOLSET", "QUALCODER_MCP_TOOLSET"),
33
+ "ai_coder_name": ("EXEGETE_AI_CODER_NAME",
34
+ "QUALCODER_MCP_AI_CODER_NAME"),
35
+ "workspace": ("EXEGETE_WORKSPACE", "QUALCODER_MCP_WORKSPACE"),
36
+ "workspace_required": ("EXEGETE_WORKSPACE_REQUIRED",
37
+ "QUALCODER_MCP_WORKSPACE_REQUIRED"),
38
+ "allow_unknown_schema": ("EXEGETE_ALLOW_UNKNOWN_SCHEMA",
39
+ "QUALCODER_MCP_ALLOW_UNKNOWN_SCHEMA"),
40
+ "project_path": ("EXEGETE_PROJECT_PATH", "QUALCODER_PROJECT_PATH"),
41
+ }
42
+ # Until when the earlier spellings are read, as messages say it.
43
+ OLD_SPELLINGS_UNTIL = "v1.0"
44
+
45
+ # The state folder in the home folder, and the one it is moved from once,
46
+ # whole, at the first real start, with a link left under the old name (a
47
+ # junction on Windows).
48
+ STATE_FOLDER = ".exegete"
49
+ OLD_STATE_FOLDER = ".qualcoder_mcp"
50
+
51
+ # The workspace inside the Documents folder: where copies and new projects
52
+ # go when no workspace is set (installs from PyPI or from the source; the
53
+ # desktop extension always sets one, `~/QualCoder projects`). The earlier
54
+ # one is never moved or emptied, and the project listing still finds its
55
+ # projects (it walks ~/Documents).
56
+ WORKSPACE_FOLDER = "Exegete projects"
57
+ OLD_WORKSPACE_FOLDER = "Qualcoder MCP Projects"
58
+
59
+ # The resources' address scheme; the earlier one is still answered, and
60
+ # no longer listed, until v1.0. (QualCoder's own
61
+ # MCP server uses `qualcoder://`, hence the departure.)
62
+ RESOURCE_SCHEME = "exegete"
63
+ OLD_RESOURCE_SCHEME = "qualcoder"
64
+
65
+ # The server's own name: in the MCP handshake, in what it says, in the
66
+ # `about` of the projects it creates and as the producer of its exports.
67
+ # Fixed by the owner's ruling (29 September 2026).
68
+ SERVER_NAME = "Exegete"