Skip to content

Commit 9df297f

Browse files
committed
feat!: resolve credentials from env vars or memory, never write files
CredentialManager resolves credentials from memory, an existing file (read-only), the OSW_USERNAME/OSW_PASSWORD environment variables (e.g. via .env), or an interactive prompt, and no longer persists them: OswExpress drops the save-path prompt and auto-save, the prompt result stays in memory, save_credentials_to_file becomes an explicit deprecated opt-in and stops editing .gitignore. Update tests to the new contract and document the resolution order. Also keep the entity model pristine after tests and clean up warnings: snapshot and restore src/osw/model/entity.py around integration test sessions (fetch_schema regenerates it into the source tree by design), remove a self-referential deprecation on StoreOntologiesParam, make FileResult.close() a silent no-op on closed files like io streams, mark deliberately triggered warnings as expected per test, and filter non-actionable third-party warnings narrowly (122 -> 1 on the integration suite). BREAKING CHANGE: credentials are no longer written to disk automatically; rely on OSW_USERNAME/OSW_PASSWORD (or a .env file), an in-memory CredentialManager, or a self-managed credentials file.
1 parent 51eab32 commit 9df297f

13 files changed

Lines changed: 177 additions & 132 deletions

docs/api/auth.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Authentication
22

3-
Credential handling for wiki and service logins.
3+
Credential handling for wiki and service logins. Credentials are resolved
4+
in this order and held in memory only:
5+
6+
1. Credentials added programmatically (`add_credential`)
7+
2. An existing credentials file (read-only, if one is configured)
8+
3. The environment variables `OSW_USERNAME` / `OSW_PASSWORD`
9+
(`OSL_*` variants work too), e.g. loaded from a `.env` file
10+
4. An interactive prompt (fallback `ask`)
11+
12+
The library never writes credentials to disk on its own.
413

514
::: osw.auth.CredentialManager

docs/get-started.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@ instances = osw.site.semantic_search("[[Category:Item]]")
5959
print(instances)
6060
```
6161

62-
Credentials are prompted for interactively or read from a credentials file;
63-
see [Authentication](api/auth.md).
62+
Credentials are resolved from the environment variables `OSW_USERNAME` /
63+
`OSW_PASSWORD` (e.g. loaded from a `.env` file), from an existing
64+
credentials file, or via an interactive prompt - and are held in memory
65+
only, never written to disk; see [Authentication](api/auth.md).
6466

6567
## Examples and tutorials
6668

pyproject.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,28 @@ testpaths = ["tests"]
135135
addopts = "--cov --cov-config=pyproject.toml --cov-report=term-missing --ignore=tests/integration"
136136
norecursedirs = ["dist", "build", ".tox"]
137137
asyncio_default_fixture_loop_scope = "function"
138+
# Third-party warnings that are not actionable in this repo. Everything
139+
# emitted by our own code is either fixed or explicitly expected via
140+
# per-test filterwarnings markers.
141+
# KNOWN RESIDUAL: a handful of pydantic class-based-config deprecations
142+
# from oold/prefect are emitted at import time under state that no pytest
143+
# or interpreter filter reaches; they still show up in the summary.
144+
filterwarnings = [
145+
# oold still uses pydantic-v1-style class-based Config (pydantic v2 deprecation)
146+
"ignore:Support for class-based `config` is deprecated:DeprecationWarning",
147+
# prefect 2.x internals: pydantic v2 deprecations and vendored starlette
148+
"ignore:The `__fields__` attribute is deprecated:DeprecationWarning",
149+
"ignore:The `__fields_set__` attribute is deprecated:DeprecationWarning",
150+
"ignore:The private method `_iter` will be removed:DeprecationWarning",
151+
"ignore:Please use `import python_multipart` instead:PendingDeprecationWarning",
152+
# sqlalchemy reflection notes from the prefect test harness
153+
"ignore:Skipped unsupported reflection",
154+
# datamodel-code-generator does not know OSW's custom JSON-schema formats
155+
"ignore:format of '.*' not understood:UserWarning",
156+
# rdflib internals
157+
"ignore:Dataset.default_context is deprecated:DeprecationWarning",
158+
"ignore:ConjunctiveGraph is deprecated:DeprecationWarning",
159+
]
138160

139161
[tool.coverage.run]
140162
branch = true

src/osw/auth.py

Lines changed: 22 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import getpass
4+
import os
5+
import warnings
46
from enum import Enum
57
from pathlib import Path
68
from typing import List, Optional, Union
@@ -160,6 +162,17 @@ def get_credential(self, config: CredentialConfig) -> BaseCredential:
160162
return match
161163
return self._oold_to_osw(match)
162164

165+
# Environment variables (e.g. loaded from a .env file) come before
166+
# any interactive fallback; credentials are kept in memory only.
167+
username = os.getenv("OSW_USERNAME") or os.getenv("OSL_USERNAME")
168+
password = os.getenv("OSW_PASSWORD") or os.getenv("OSL_PASSWORD")
169+
if username is not None and password is not None:
170+
cred = CredentialManager.UserPwdCredential(
171+
username=username, password=password, iri=config.iri
172+
)
173+
self.add_credential(cred)
174+
return cred
175+
163176
if config.fallback is CredentialManager.CredentialFallback.ask:
164177
if self.cred_filepath:
165178
filepath_str = "', '".join([str(fp) for fp in self.cred_filepath])
@@ -172,9 +185,9 @@ def get_credential(self, config: CredentialConfig) -> BaseCredential:
172185
cred = CredentialManager.UserPwdCredential(
173186
username=username, password=password, iri=config.iri
174187
)
188+
# kept in memory only; persisting credentials to a file happens
189+
# exclusively through an explicit save_credentials_to_file() call
175190
self.add_credential(cred)
176-
if self.cred_filepath:
177-
self.save_credentials_to_file()
178191
return cred
179192

180193
return None
@@ -250,6 +263,13 @@ def save_credentials_to_file(
250263
If True, the cred_filepath is set to the given filepath. If False, the
251264
cred_filepath of the CredentialManager is not changed.
252265
"""
266+
warnings.warn(
267+
"save_credentials_to_file() writes credentials to disk in clear "
268+
"text and is deprecated. Prefer environment variables (e.g. via "
269+
"a .env file) or in-memory credentials.",
270+
DeprecationWarning,
271+
stacklevel=2,
272+
)
253273
cred_filepaths = [filepath]
254274
if filepath is None:
255275
cred_filepaths = self.cred_filepath
@@ -276,65 +296,5 @@ def save_credentials_to_file(
276296
else:
277297
print(f"Credentials file created at '{fp.resolve()}'.")
278298

279-
# Creating or updating .gitignore file in the working directory
280-
cwd = Path.cwd()
281-
potential_fp = [
282-
cwd / ".gitignore",
283-
cwd.parent / ".gitignore",
284-
]
285-
gitignore_fp = potential_fp[0]
286-
# Stops if a .gitignore file is found
287-
for fp in potential_fp:
288-
if fp.exists():
289-
gitignore_fp = fp
290-
break
291-
# Creates a .gitignore file if none is found
292-
if not gitignore_fp.exists():
293-
if not gitignore_fp.parent.exists():
294-
gitignore_fp.parent.mkdir(parents=True)
295-
gitignore_fp.touch()
296-
# Reads the .gitignore file
297-
with open(gitignore_fp) as stream:
298-
content = stream.read()
299-
comment_set = False
300-
osw_dir_added = False
301-
# For every file path in the list of credentials file paths
302-
for _ii, fp in enumerate(cred_filepaths):
303-
if default_paths.osw_files_dir in fp.parents and not osw_dir_added:
304-
msg = (
305-
f"Adding '{default_paths.osw_files_dir}' to gitignore file "
306-
f"'{gitignore_fp}'."
307-
)
308-
containing_gitignore = gitignore_fp.parent.absolute()
309-
if containing_gitignore in default_paths.osw_files_dir.parents:
310-
# If the default_path.osw_files_dir is a subdirectory of the directory
311-
# containing the .gitignore file, add the relative path to the
312-
# .gitignore file
313-
rel = default_paths.osw_files_dir.relative_to(containing_gitignore)
314-
to_add = f"\n*/{rel.as_posix()!s}/*"
315-
else:
316-
# Test if the default_path.osw_files_dir is a subdirectory of the
317-
# directory containing the .gitignore file
318-
to_add = (
319-
f"\n*/{default_paths.osw_files_dir.absolute().as_posix()}/*"
320-
)
321-
osw_dir_added = True
322-
else:
323-
msg = f"Adding '{fp.name}' to gitignore file '{gitignore_fp}'."
324-
to_add = f"\n*/{fp.name}"
325-
if not to_add or to_add in content:
326-
continue
327-
print(msg)
328-
with open(gitignore_fp, "a") as stream:
329-
# Only add comment if not already set
330-
comment = (
331-
"\n# Automatically added by osw.auth.CredentialManager."
332-
"save_credentials_to_file:"
333-
)
334-
if not comment_set and comment not in content:
335-
stream.write(comment)
336-
comment_set = True
337-
stream.write(to_add)
338-
339299

340300
CredentialManager.CredentialConfig.update_forward_refs()

src/osw/defaults.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@ def update_attr(set_attr, to_update, old_val, new_val):
120120
old_rel_path = getattr(self, attr_name).relative_to(old_val)
121121
new_rel_path = new_val / old_rel_path
122122
setattr(self, attr_name, new_rel_path)
123+
cls_name = type(self).__name__
123124
print(
124-
f"Following the setting of {self.__name__}.{set_attr}, "
125-
f"{self.__name__}.{attr_name} was updated to {new_rel_path}."
125+
f"Following the setting of {cls_name}.{set_attr}, "
126+
f"{cls_name}.{attr_name} was updated to {new_rel_path}."
126127
)
127128

128129
if attr_name == "base":

src/osw/express.py

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -112,55 +112,31 @@ def __init__(
112112
# If a credential manager is explicitly defined, that should have priority
113113
cred_filepath = cred_mngr.cred_filepath[0]
114114
if cred_filepath is None:
115-
# If no credential file path is given, try to take it from environment vars
115+
# If no credential file path is given, try to take it from environment
116+
# vars. A credential file is only ever READ; credentials obtained from
117+
# environment variables (OSW_USERNAME/OSW_PASSWORD, e.g. loaded from a
118+
# .env file) or an interactive prompt are kept in memory only.
116119
if os.getenv("OSW_CRED_FILEPATH") is not None:
117120
cred_filepath = os.getenv("OSW_CRED_FILEPATH")
118121
elif os.getenv("OSL_CRED_FILEPATH") is not None:
119122
cred_filepath = os.getenv("OSL_CRED_FILEPATH")
120-
else:
121-
# Otherwise, prompt user to set cred_filepath
122-
cred_filepath = input(
123-
"No credential file path was provided. Please specify, where to "
124-
"save the credential file: "
125-
)
123+
if cred_filepath is not None:
124+
if not isinstance(cred_filepath, Path):
125+
cred_filepath = Path(cred_filepath)
126+
if not cred_filepath.is_file():
126127
print(
127-
f"Credential file path changed to '{cred_filepath}'."
128-
"\nPlease set environment variable 'OSW_CRED_FILEPATH' accordingly."
129-
"\nIf adequate, make sure to load the .env file."
128+
f"Credential file '{cred_filepath}' does not exist and will "
129+
"be ignored. Credentials are taken from the environment "
130+
"variables OSW_USERNAME/OSW_PASSWORD or an interactive "
131+
"prompt instead (in-memory only)."
130132
)
131-
if not isinstance(cred_filepath, Path):
132-
cred_filepath = Path(cred_filepath)
133-
if not cred_filepath.is_file():
134-
print(f"Credential file '{cred_filepath}' is not a file. ")
135-
if not cred_filepath.exists():
136-
print(
137-
f"Credential file '{cred_filepath}' does not exist and will be created."
138-
)
133+
cred_filepath = None
139134
if cred_mngr is None:
140135
# Create a credentials manager
141136
if cred_filepath is None:
142137
cred_mngr = CredentialManager()
143138
else: # Reuse passed cred_filepath
144139
cred_mngr = CredentialManager(cred_filepath=cred_filepath)
145-
if not cred_mngr.iri_in_file(domain):
146-
cred = cred_mngr.get_credential(
147-
CredentialManager.CredentialConfig(
148-
iri=domain,
149-
fallback=CredentialManager.CredentialFallback.ask,
150-
)
151-
)
152-
cred_mngr.add_credential(cred)
153-
# If there was no cred_filepath specified within the CredentialManager
154-
# the filepath from the OswExpress constructor will be used (either passed
155-
# as argument or set by default)
156-
if cred_mngr.cred_filepath is None:
157-
cred_mngr.save_credentials_to_file(
158-
filepath=cred_filepath, set_cred_filepath=True
159-
)
160-
# If there was a cred_filepath specified within the CredentialManager,
161-
# that filepath will be used
162-
else:
163-
cred_mngr.save_credentials_to_file()
164140
# Test if domain is reachable
165141
try:
166142
url = f"https://{domain}/wiki/Main_Page"
@@ -358,10 +334,9 @@ def open(self, mode: str = None, **kwargs) -> TextIO:
358334
return self.file_io
359335

360336
def close(self) -> None:
361-
"""Close the file, if not already closed."""
362-
if self.file_io is None or self.file_io.closed:
363-
warn("File already closed or not opened.")
364-
else:
337+
"""Close the file. Like io streams, closing an already closed (or
338+
never opened) file is a silent no-op."""
339+
if self.file_io is not None and not self.file_io.closed:
365340
self.file_io.close()
366341

367342
def read(self, n: int = -1) -> AnyStr:

src/osw/ontology.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from pydantic.v1 import PrivateAttr
99
from pyld import jsonld
1010
from rdflib import Graph
11-
from typing_extensions import deprecated
1211

1312
from osw.core import OSW, model
1413
from osw.utils.strings import camel_case, pascal_case
@@ -832,7 +831,6 @@ def _store_ontology(self, param: StoreOntologyParam):
832831
)
833832
)
834833

835-
@deprecated("use ontology.OntologyImporter.StoreOntologiesParam instead")
836834
class StoreOntologiesParam(model.OswBaseModel):
837835
entities: Optional[List[model.OswBaseModel]]
838836
"""If we use model.Entity here, all instances are casted to model.Entity

tests/integration/conftest.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Working-directory isolation for the integration tests.
2+
3+
The tests and the library defaults create files relative to the current
4+
working directory - among them credential files in CLEAR TEXT
5+
(accounts.pwd.yaml and domain-named files) and download caches
6+
(osw_files/). Running each test in its own pytest tmp dir keeps all of
7+
that out of the repository tree; pytest cleans its tmp dirs up on its
8+
own. Never rely on .gitignore for credential artifacts.
9+
"""
10+
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
import osw.model.entity
16+
from osw.defaults import paths as default_paths
17+
18+
19+
@pytest.fixture(autouse=True, scope="session")
20+
def _restore_entity_model():
21+
"""Snapshot and restore src/osw/model/entity.py around the test session.
22+
23+
OSW.fetch_schema regenerates the entity model INTO THE INSTALLED
24+
PACKAGE by design, which in an editable install is the source tree.
25+
Restoring the pre-session content keeps the working tree clean after
26+
`make test-integration` without relying on git or .gitignore.
27+
"""
28+
model_file = Path(osw.model.entity.__file__)
29+
snapshot = model_file.read_bytes()
30+
yield
31+
if model_file.read_bytes() != snapshot:
32+
model_file.write_bytes(snapshot)
33+
print(f"\nRestored pre-session state of '{model_file}'.")
34+
35+
36+
@pytest.fixture(autouse=True)
37+
def _isolate_working_dir(tmp_path, monkeypatch):
38+
# covers every Path.cwd()-relative write in the tests themselves
39+
monkeypatch.chdir(tmp_path)
40+
# osw.defaults captures Path.cwd() at import time, so the singleton
41+
# still points into the repo; re-base it for the duration of the
42+
# test (setting `base` cascades to the dependent paths) and restore
43+
# the previous values afterwards.
44+
old = {
45+
"base": default_paths.base,
46+
"osw_files_dir": default_paths.osw_files_dir,
47+
"cred_filepath": default_paths.cred_filepath,
48+
"download_dir": default_paths.download_dir,
49+
}
50+
default_paths.base = tmp_path
51+
yield
52+
for name, value in old.items():
53+
setattr(default_paths, name, value)

tests/integration/ontology_import_test.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# uv run pytest tests/integration -o addopts="" --wiki_domain domain --wiki_username user --wiki_password pass
1212

1313

14-
def test_ontology_import(wiki_domain, wiki_username, wiki_password):
14+
def test_ontology_import(wiki_domain, wiki_username, wiki_password, tmp_path):
1515
cm = CredentialManager()
1616
cm.add_credential(
1717
CredentialManager.UserPwdCredential(
@@ -62,7 +62,8 @@ def test_ontology_import(wiki_domain, wiki_username, wiki_password):
6262
base_class=model.OwlClass,
6363
base_class_title="Category:OSW725a3cf5458f4daea86615fcbd0029f8", # OwlClass
6464
dump_files=True,
65-
dump_path=os.path.dirname(os.path.abspath(__file__)),
65+
# dump into the pytest tmp dir, not the module directory
66+
dump_path=str(tmp_path),
6667
dry_run=False,
6768
)
6869

0 commit comments

Comments
 (0)