adapt-server 0.2.4__tar.gz → 0.2.6__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.
Files changed (116) hide show
  1. {adapt_server-0.2.4 → adapt_server-0.2.6}/PKG-INFO +3 -1
  2. {adapt_server-0.2.4 → adapt_server-0.2.6}/README.md +2 -0
  3. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/__init__.py +1 -1
  4. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/cache.py +19 -11
  5. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/cli.py +19 -1
  6. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/addsuperuser.py +19 -4
  7. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/__init__.py +3 -1
  8. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/create_user.py +17 -3
  9. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/check.py +3 -3
  10. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/list_endpoints.py +1 -1
  11. adapt_server-0.2.6/adapt/commands/passwords.py +116 -0
  12. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/config.py +10 -1
  13. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/locks.py +8 -4
  14. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/mcp.py +23 -5
  15. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/__init__.py +3 -1
  16. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/base.py +31 -3
  17. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/csv_plugin.py +9 -12
  18. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/excel_plugin.py +6 -8
  19. adapt_server-0.2.6/adapt/plugins/file_plugin.py +108 -0
  20. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/html_plugin.py +7 -16
  21. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/parquet_plugin.py +11 -14
  22. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/search.py +1 -1
  23. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/static/admin/app.js +30 -7
  24. adapt_server-0.2.6/adapt/static/sortable-tables.css +30 -0
  25. adapt_server-0.2.6/adapt/static/sortable-tables.js +229 -0
  26. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/admin/index.html +27 -27
  27. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/admin_base.html +3 -1
  28. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/base.html +3 -1
  29. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/landing.html +23 -13
  30. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/profile.html +10 -7
  31. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/utils/__init__.py +5 -5
  32. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/PKG-INFO +3 -1
  33. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/SOURCES.txt +5 -0
  34. {adapt_server-0.2.4 → adapt_server-0.2.6}/pyproject.toml +1 -1
  35. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_admin.py +138 -1
  36. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_cache.py +7 -0
  37. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_cli_integration.py +20 -0
  38. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_csv_plugin.py +63 -1
  39. adapt_server-0.2.6/tests/test_file_plugin.py +109 -0
  40. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_html_plugin.py +8 -2
  41. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_integration.py +61 -1
  42. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_locks.py +17 -8
  43. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_mcp.py +12 -0
  44. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_parquet_plugin.py +49 -0
  45. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_plugin_interface.py +2 -0
  46. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_search.py +9 -0
  47. {adapt_server-0.2.4 → adapt_server-0.2.6}/LICENSE +0 -0
  48. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/__main__.py +0 -0
  49. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/__init__.py +0 -0
  50. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/api_keys.py +0 -0
  51. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/audit_logs.py +0 -0
  52. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/cache.py +0 -0
  53. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/groups.py +0 -0
  54. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/locks.py +0 -0
  55. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/models.py +0 -0
  56. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/permissions.py +0 -0
  57. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/resources.py +0 -0
  58. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/ui.py +0 -0
  59. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/users.py +0 -0
  60. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/api_keys.py +0 -0
  61. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/app.py +0 -0
  62. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/audit.py +0 -0
  63. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/__init__.py +0 -0
  64. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/dependencies.py +0 -0
  65. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/password.py +0 -0
  66. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/routes.py +0 -0
  67. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/session.py +0 -0
  68. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/__init__.py +0 -0
  69. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/add_to_group.py +0 -0
  70. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/create_group.py +0 -0
  71. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/create_permissions.py +0 -0
  72. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/delete_group.py +0 -0
  73. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/delete_user.py +0 -0
  74. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/list_groups.py +0 -0
  75. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/list_resources.py +0 -0
  76. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/list_users.py +0 -0
  77. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/remove_from_group.py +0 -0
  78. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/reindex.py +0 -0
  79. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/serve.py +0 -0
  80. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/discovery.py +0 -0
  81. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/models.py +0 -0
  82. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/permissions.py +0 -0
  83. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/dataset_plugin.py +0 -0
  84. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/markdown_plugin.py +0 -0
  85. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/media_plugin.py +0 -0
  86. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/python_plugin.py +0 -0
  87. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/routes.py +0 -0
  88. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/routes_search.py +0 -0
  89. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/security.py +0 -0
  90. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/security_urls.py +0 -0
  91. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/static/admin/style.css +0 -0
  92. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/storage.py +0 -0
  93. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/datatable.html +0 -0
  94. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/login.html +0 -0
  95. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/media_gallery.html +0 -0
  96. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/media_player.html +0 -0
  97. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/search_results.html +0 -0
  98. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/utils/query.py +0 -0
  99. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/dependency_links.txt +0 -0
  100. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/entry_points.txt +0 -0
  101. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/requires.txt +0 -0
  102. {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/top_level.txt +0 -0
  103. {adapt_server-0.2.4 → adapt_server-0.2.6}/setup.cfg +0 -0
  104. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_auth.py +0 -0
  105. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_config.py +0 -0
  106. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_constraints.py +0 -0
  107. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_dataset_plugin.py +0 -0
  108. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_dependencies.py +0 -0
  109. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_discovery.py +0 -0
  110. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_markdown_plugin.py +0 -0
  111. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_on_delete.py +0 -0
  112. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_phase3.py +0 -0
  113. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_python_plugin.py +0 -0
  114. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_readonly.py +0 -0
  115. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_routes.py +0 -0
  116. {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_security_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adapt-server
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs.
5
5
  Author-email: notesofcliff <notesofcliff@gmail.com>
6
6
  License-Expression: MIT
@@ -207,6 +207,8 @@ anonymous path, since MCP has no concept of a browser session. Set
207
207
  `mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
208
208
  remove `/mcp` entirely. Full walkthrough, troubleshooting, and the tool
209
209
  reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
210
+ For dataset reads, `sort` is the column name and `order` must be `asc` or
211
+ `desc`.
210
212
 
211
213
  ## Dataset Mutation Envelope
212
214
 
@@ -161,6 +161,8 @@ anonymous path, since MCP has no concept of a browser session. Set
161
161
  `mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
162
162
  remove `/mcp` entirely. Full walkthrough, troubleshooting, and the tool
163
163
  reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
164
+ For dataset reads, `sort` is the column name and `order` must be `asc` or
165
+ `desc`.
164
166
 
165
167
  ## Dataset Mutation Envelope
166
168
 
@@ -1,3 +1,3 @@
1
1
  """Adapt package namespace."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.6"
@@ -8,12 +8,22 @@ import threading
8
8
  import logging
9
9
  from datetime import datetime, timedelta, timezone
10
10
  import pickle
11
+ from pathlib import Path
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
14
15
  CACHE_TABLE = "cache"
15
- _lock = threading.Lock()
16
+ _lock = threading.RLock()
16
17
  _db_path: str | None = None
18
+ _CREATE_CACHE_TABLE_SQL = f"""
19
+ CREATE TABLE IF NOT EXISTS {CACHE_TABLE} (
20
+ key TEXT PRIMARY KEY,
21
+ value BLOB,
22
+ expires_at DATETIME,
23
+ resource TEXT,
24
+ user TEXT
25
+ )
26
+ """
17
27
 
18
28
 
19
29
  def _default_db_path() -> str:
@@ -35,24 +45,22 @@ def configure(db_path: str | None) -> None:
35
45
  def _get_conn():
36
46
  """Get a SQLite connection to the cache database."""
37
47
  path = _db_path or _default_db_path()
48
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
38
49
  conn = sqlite3.connect(path, check_same_thread=False)
39
50
  conn.row_factory = sqlite3.Row
51
+ _ensure_cache_table(conn)
40
52
  return conn
41
53
 
54
+
55
+ def _ensure_cache_table(conn: sqlite3.Connection) -> None:
56
+ """Ensure the cache table exists for the active database connection."""
57
+ conn.execute(_CREATE_CACHE_TABLE_SQL)
58
+ conn.commit()
59
+
42
60
  def init_cache_table():
43
61
  """Initialize the cache table in the SQLite database."""
44
62
  with _lock:
45
63
  conn = _get_conn()
46
- conn.execute(f"""
47
- CREATE TABLE IF NOT EXISTS {CACHE_TABLE} (
48
- key TEXT PRIMARY KEY,
49
- value BLOB,
50
- expires_at DATETIME,
51
- resource TEXT,
52
- user TEXT
53
- )
54
- """)
55
- conn.commit()
56
64
  conn.close()
57
65
  logger.debug("Cache table initialized")
58
66
 
@@ -36,6 +36,12 @@ def main() -> None:
36
36
  user_parser.add_argument("root", nargs="?", default=".", help="Document root containing the SQLite store")
37
37
  user_parser.add_argument("--username", required=True, help="Username for the superuser")
38
38
  user_parser.add_argument("--password", help="Password (will prompt if missing)")
39
+ user_parser.add_argument("--password-confirm", help="Password confirmation for non-interactive use")
40
+ user_parser.add_argument(
41
+ "--allow-weak-password",
42
+ action="store_true",
43
+ help="Allow a weak password without the safety confirmation prompt",
44
+ )
39
45
 
40
46
  list_parser = subparsers.add_parser("list-endpoints", help="List the auto-generated REST/UI endpoints")
41
47
  list_parser.add_argument("root", nargs="?", default=".", help="Document root to inspect")
@@ -65,6 +71,12 @@ def main() -> None:
65
71
  admin_create_user_parser.add_argument("root", nargs="?", default=".", help="Document root")
66
72
  admin_create_user_parser.add_argument("--username", required=True, help="Username")
67
73
  admin_create_user_parser.add_argument("--password", help="Password (will prompt if missing)")
74
+ admin_create_user_parser.add_argument("--password-confirm", help="Password confirmation for non-interactive use")
75
+ admin_create_user_parser.add_argument(
76
+ "--allow-weak-password",
77
+ action="store_true",
78
+ help="Allow a weak password without the safety confirmation prompt",
79
+ )
68
80
  admin_create_user_parser.add_argument("--superuser", action="store_true", help="Create as superuser")
69
81
 
70
82
  admin_delete_user_parser = admin_subparsers.add_parser("delete-user", help="Delete user")
@@ -116,7 +128,13 @@ def main() -> None:
116
128
  check.run_check(Path(args.root).resolve())
117
129
  elif args.command == "addsuperuser":
118
130
  logger.info("Running addsuperuser command for username=%s", args.username)
119
- addsuperuser.run_add_superuser(Path(args.root).resolve(), args.username, args.password)
131
+ addsuperuser.run_add_superuser(
132
+ Path(args.root).resolve(),
133
+ args.username,
134
+ args.password,
135
+ password_confirm=args.password_confirm,
136
+ allow_weak_password=args.allow_weak_password,
137
+ )
120
138
  elif args.command == "list-endpoints":
121
139
  logger.info("Running list-endpoints command with root=%s", args.root)
122
140
  list_endpoints.run_list_endpoints(Path(args.root).resolve())
@@ -1,4 +1,3 @@
1
- import getpass
2
1
  from pathlib import Path
3
2
  import logging
4
3
 
@@ -7,17 +6,26 @@ from sqlmodel import Session, select
7
6
  from ..config import AdaptConfig
8
7
  from ..storage import User, init_database
9
8
  from ..auth.password import hash_password
9
+ from .passwords import resolve_password
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
13
13
 
14
- def run_add_superuser(root: Path, username: str, password: str | None) -> None:
14
+ def run_add_superuser(
15
+ root: Path,
16
+ username: str,
17
+ password: str | None,
18
+ password_confirm: str | None = None,
19
+ allow_weak_password: bool = False,
20
+ ) -> None:
15
21
  """Create a new superuser account.
16
22
 
17
23
  Args:
18
24
  root: The root directory path for the Adapt configuration.
19
25
  username: The username for the new superuser.
20
26
  password: The password for the new superuser. If None, prompts for input.
27
+ password_confirm: Confirmation for non-interactive password entry.
28
+ allow_weak_password: Allow weak passwords without an interactive override prompt.
21
29
 
22
30
  Returns:
23
31
  None
@@ -27,8 +35,15 @@ def run_add_superuser(root: Path, username: str, password: str | None) -> None:
27
35
  """
28
36
  config = AdaptConfig(root=root)
29
37
  engine = init_database(config.db_path)
38
+ password = resolve_password(
39
+ username=username,
40
+ password=password,
41
+ password_confirm=password_confirm,
42
+ allow_weak_password=allow_weak_password,
43
+ )
30
44
  if password is None:
31
- password = getpass.getpass("Password: ")
45
+ logger.warning("Aborted superuser creation for %s due to password validation", username)
46
+ return
32
47
 
33
48
  hashed = hash_password(password)
34
49
  logger.debug("Hashed password for user %s", username)
@@ -43,4 +58,4 @@ def run_add_superuser(root: Path, username: str, password: str | None) -> None:
43
58
  session.add(user)
44
59
  session.commit()
45
60
  logger.info("Created superuser '%s'", username)
46
- print(f"Created superuser '{username}'")
61
+ print(f"Created superuser '{username}'")
@@ -51,7 +51,9 @@ def run_admin(args):
51
51
  root=Path(args.root).resolve(),
52
52
  username=args.username,
53
53
  password=args.password,
54
+ password_confirm=args.password_confirm,
54
55
  is_superuser=args.superuser,
56
+ allow_weak_password=args.allow_weak_password,
55
57
  )
56
58
  elif args.admin_command == "delete-user":
57
59
  logger.debug("Running admin command: delete-user for username=%s", args.username)
@@ -70,4 +72,4 @@ def run_admin(args):
70
72
  run_remove_from_group(Path(args.root).resolve(), args.username, args.group)
71
73
  else:
72
74
  logger.error("Unknown admin command: %s", args.admin_command)
73
- raise ValueError(f"Unknown admin command: {args.admin_command}")
75
+ raise ValueError(f"Unknown admin command: {args.admin_command}")
@@ -1,4 +1,3 @@
1
- import getpass
2
1
  from pathlib import Path
3
2
  import logging
4
3
 
@@ -7,17 +6,32 @@ from sqlmodel import Session, select
7
6
  from ...auth.password import hash_password
8
7
  from ...config import AdaptConfig
9
8
  from ...storage import User, init_database
9
+ from ..passwords import resolve_password
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
13
13
 
14
- def run_create_user(root: Path, username: str, password: str | None, is_superuser: bool = False) -> None:
14
+ def run_create_user(
15
+ root: Path,
16
+ username: str,
17
+ password: str | None,
18
+ password_confirm: str | None = None,
19
+ is_superuser: bool = False,
20
+ allow_weak_password: bool = False,
21
+ ) -> None:
15
22
  """Create a user account."""
16
23
  config = AdaptConfig(root=root)
17
24
  engine = init_database(config.db_path)
18
25
 
26
+ password = resolve_password(
27
+ username=username,
28
+ password=password,
29
+ password_confirm=password_confirm,
30
+ allow_weak_password=allow_weak_password,
31
+ )
19
32
  if password is None:
20
- password = getpass.getpass("Password: ")
33
+ logger.warning("Aborted user creation for %s due to password validation", username)
34
+ return
21
35
 
22
36
  with Session(engine) as db:
23
37
  existing = db.exec(select(User).where(User.username == username)).first()
@@ -38,10 +38,10 @@ def run_check(root: Path) -> None:
38
38
  print(f"SQLite store: {config.db_path} (engine {engine})")
39
39
  print(f"Discovered {count} dataset(s)")
40
40
 
41
- # Markdown and HTML resources mount at /<namespace>, so they can collide
42
- # with built-in routes; datasets live under /api and /ui and cannot.
41
+ # Markdown, HTML, and file resources mount at /<namespace>, so they can
42
+ # collide with built-in routes; datasets live under /api and /ui and cannot.
43
43
  for resource in resources:
44
- if resource.resource_type not in ("markdown", "html"):
44
+ if resource.resource_type not in ("markdown", "html", "file"):
45
45
  continue
46
46
  namespace = resource.relative_path.with_suffix("").as_posix()
47
47
  if namespace in RESERVED_NAMESPACES:
@@ -34,7 +34,7 @@ def run_list_endpoints(root: Path) -> None:
34
34
  logger.debug("Listing endpoints for %d resources", len(resources))
35
35
  for resource in resources:
36
36
  namespace = resource.relative_path.with_suffix("").as_posix()
37
- if resource.resource_type not in ("html", "markdown"):
37
+ if resource.resource_type not in ("html", "markdown", "file"):
38
38
  print(f"/api/{namespace}")
39
39
  print(f"/ui/{namespace}")
40
40
  print(f"/schema/{namespace}")
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import logging
5
+ import re
6
+ import sys
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ _COMMON_PASSWORDS = {
11
+ "123456",
12
+ "12345678",
13
+ "123456789",
14
+ "admin",
15
+ "admin123",
16
+ "changeme",
17
+ "letmein",
18
+ "password",
19
+ "password1",
20
+ "password123",
21
+ "qwerty",
22
+ "welcome",
23
+ }
24
+ _COMMON_SEQUENCES = ("12345", "54321", "abcdef", "fedcba", "qwerty", "asdf", "zxcv")
25
+
26
+
27
+ def is_weak_password(password: str, username: str | None = None) -> bool:
28
+ """Return True when the password is easily guessed by common heuristics."""
29
+ normalized = password.casefold()
30
+ compact = re.sub(r"[^a-z0-9]", "", normalized)
31
+
32
+ if len(password) < 10:
33
+ return True
34
+ if normalized in _COMMON_PASSWORDS or compact in _COMMON_PASSWORDS:
35
+ return True
36
+ if username and username.casefold() in normalized:
37
+ return True
38
+ if len(set(normalized)) <= 2:
39
+ return True
40
+ if any(sequence in compact for sequence in _COMMON_SEQUENCES):
41
+ return True
42
+
43
+ classes = sum(
44
+ (
45
+ any(ch.islower() for ch in password),
46
+ any(ch.isupper() for ch in password),
47
+ any(ch.isdigit() for ch in password),
48
+ any(not ch.isalnum() for ch in password),
49
+ )
50
+ )
51
+ if len(password) >= 12 and classes >= 3:
52
+ return False
53
+ return not (len(password) >= 10 and classes == 4)
54
+
55
+
56
+ def resolve_password(
57
+ *,
58
+ username: str,
59
+ password: str | None,
60
+ password_confirm: str | None = None,
61
+ allow_weak_password: bool = False,
62
+ ) -> str | None:
63
+ """Collect, confirm, and validate a password for CLI user creation."""
64
+ if password is None:
65
+ return _prompt_for_password(username=username)
66
+
67
+ if password_confirm is None:
68
+ if not sys.stdin.isatty():
69
+ print("Password confirmation required. Re-run with --password-confirm for non-interactive use.")
70
+ return None
71
+ password_confirm = getpass.getpass("Confirm password: ")
72
+
73
+ if password != password_confirm:
74
+ logger.warning("Password confirmation mismatch for user %s", username)
75
+ print("Passwords do not match.")
76
+ return None
77
+
78
+ if is_weak_password(password, username=username):
79
+ if allow_weak_password:
80
+ return password
81
+ if not sys.stdin.isatty():
82
+ print(
83
+ "This password appears weak and may be easily guessed. "
84
+ "Choose a stronger password or re-run with --allow-weak-password."
85
+ )
86
+ return None
87
+ if not _confirm_weak_password():
88
+ print("Password not accepted.")
89
+ return None
90
+
91
+ return password
92
+
93
+
94
+ def _prompt_for_password(*, username: str) -> str:
95
+ while True:
96
+ password = getpass.getpass("Password: ")
97
+ password_confirm = getpass.getpass("Confirm password: ")
98
+ if password != password_confirm:
99
+ logger.warning("Password confirmation mismatch for user %s", username)
100
+ print("Passwords do not match. Please try again.")
101
+ continue
102
+ if is_weak_password(password, username=username) and not _confirm_weak_password():
103
+ print("Please choose a stronger password.")
104
+ continue
105
+ return password
106
+
107
+
108
+ def _confirm_weak_password() -> bool:
109
+ while True:
110
+ answer = input("This password appears weak and may be easily guessed. Use it anyway? [y/N]: ")
111
+ normalized = answer.strip().casefold()
112
+ if normalized in {"y", "yes"}:
113
+ return True
114
+ if normalized in {"", "n", "no"}:
115
+ return False
116
+ print("Please answer 'y' or 'n'.")
@@ -45,7 +45,16 @@ class AdaptConfig:
45
45
  ".parquet": "adapt.plugins.parquet_plugin.ParquetPlugin",
46
46
  ".py": "adapt.plugins.python_plugin.PythonHandlerPlugin",
47
47
  ".html": "adapt.plugins.html_plugin.HtmlPlugin",
48
- ".txt": "adapt.plugins.html_plugin.HtmlPlugin",
48
+ ".txt": "adapt.plugins.file_plugin.FilePlugin",
49
+ ".pdf": "adapt.plugins.file_plugin.FilePlugin",
50
+ ".json": "adapt.plugins.file_plugin.FilePlugin",
51
+ ".xml": "adapt.plugins.file_plugin.FilePlugin",
52
+ ".svg": "adapt.plugins.file_plugin.FilePlugin",
53
+ ".png": "adapt.plugins.file_plugin.FilePlugin",
54
+ ".jpg": "adapt.plugins.file_plugin.FilePlugin",
55
+ ".jpeg": "adapt.plugins.file_plugin.FilePlugin",
56
+ ".gif": "adapt.plugins.file_plugin.FilePlugin",
57
+ ".webp": "adapt.plugins.file_plugin.FilePlugin",
49
58
  ".md": "adapt.plugins.markdown_plugin.MarkdownPlugin",
50
59
  ".mp4": "adapt.plugins.media_plugin.MediaPlugin",
51
60
  ".mp3": "adapt.plugins.media_plugin.MediaPlugin",
@@ -47,18 +47,22 @@ class LockManager:
47
47
  return lock
48
48
  except IntegrityError:
49
49
  db.rollback()
50
- # Lock exists, check if expired
50
+ # Another writer won the unique-constraint race. Only retry if we can
51
+ # positively reclaim an expired row; otherwise report a clean conflict.
51
52
  existing = db.exec(select(LockRecord).where(LockRecord.resource == resource)).first()
52
53
  if existing and (existing.expires_at is None or existing.expires_at.replace(tzinfo=timezone.utc) > now):
53
54
  logger.warning(f"Lock already exists for {resource} by {existing.owner}")
54
55
  raise RuntimeError(f"Resource '{resource}' is already locked by {existing.owner}")
55
- else:
56
- # Expired lock, delete and retry
56
+
57
+ if existing is not None:
58
+ logger.debug(f"Expired lock found for {resource}, removing stale row")
57
59
  db.delete(existing)
58
60
  db.commit()
59
- logger.debug(f"Expired lock removed for {resource}, retrying")
60
61
  return self.acquire_lock(resource, owner, reason, ttl_seconds)
61
62
 
63
+ logger.warning(f"Lock acquisition race detected for {resource} with no remaining row")
64
+ raise RuntimeError(f"Resource '{resource}' is already locked")
65
+
62
66
  def release_lock(self, lock_id: int) -> bool:
63
67
  """Release the lock with *lock_id*; returns True if a lock was deleted."""
64
68
  logger.info(f"Releasing lock {lock_id}")
@@ -8,11 +8,12 @@ API surface, no duplicated authorization logic.
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
- from typing import Any, Literal
11
+ from typing import Annotated, Any, Literal
12
12
 
13
13
  from fastapi import HTTPException, Request
14
14
  from mcp.server.fastmcp import Context, FastMCP
15
15
  from mcp.server.fastmcp.exceptions import ToolError
16
+ from pydantic import Field
16
17
  from sqlmodel import Session
17
18
 
18
19
  from .auth.dependencies import check_permission, get_current_user
@@ -26,6 +27,19 @@ from .storage import User
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
30
+ SortParam = Annotated[
31
+ str | None,
32
+ Field(description="Dataset column name to sort by, for example `category` or `price`."),
33
+ ]
34
+ OrderParam = Annotated[
35
+ Literal["asc", "desc"],
36
+ Field(description="Sort direction for dataset reads. Use `sort` for the column name and `order` for `asc` or `desc`."),
37
+ ]
38
+ FilterParam = Annotated[
39
+ dict[str, Any] | None,
40
+ Field(description="Optional dataset filter object, using the same shape as the REST API."),
41
+ ]
42
+
29
43
 
30
44
  async def _authenticated_user(ctx: Context) -> User:
31
45
  """Resolve the calling user from the request's `X-API-Key` header.
@@ -102,11 +116,15 @@ def build_mcp_server(config: AdaptConfig) -> FastMCP:
102
116
  ctx: Context,
103
117
  limit: int | None = None,
104
118
  offset: int = 0,
105
- sort: str | None = None,
106
- order: str = "asc",
107
- filter: dict | None = None,
119
+ sort: SortParam = None,
120
+ order: OrderParam = "asc",
121
+ filter: FilterParam = None,
108
122
  ) -> Any:
109
- """Read a resource's content, permission-checked like the REST API."""
123
+ """Read a resource's content, permission-checked like the REST API.
124
+
125
+ For dataset resources, `sort` is the column name and `order` must be
126
+ `asc` or `desc`.
127
+ """
110
128
  request = ctx.request_context.request
111
129
  user = await _authenticated_user(ctx)
112
130
  entry = _authorized_entry(request, user, resource, "read")
@@ -5,6 +5,7 @@ logger = logging.getLogger(__name__)
5
5
  from .base import Plugin, PluginContext, ResourceDescriptor, discover_plugins
6
6
  from .csv_plugin import CsvPlugin
7
7
  from .excel_plugin import ExcelPlugin
8
+ from .file_plugin import FilePlugin
8
9
  from .media_plugin import MediaPlugin
9
10
  from .python_plugin import PythonHandlerPlugin
10
11
 
@@ -17,6 +18,7 @@ __all__ = [
17
18
  "discover_plugins",
18
19
  "CsvPlugin",
19
20
  "ExcelPlugin",
21
+ "FilePlugin",
20
22
  "MediaPlugin",
21
23
  "PythonHandlerPlugin",
22
- ]
24
+ ]
@@ -3,9 +3,13 @@ from __future__ import annotations
3
3
 
4
4
  from abc import ABC, abstractmethod
5
5
  from dataclasses import dataclass, field
6
- from pathlib import Path
7
- from typing import Any, Iterable, Sequence, TYPE_CHECKING
6
+ import errno
8
7
  import logging
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import tempfile
12
+ from typing import Any, Callable, Iterable, Sequence, TYPE_CHECKING
9
13
 
10
14
  from fastapi import Request
11
15
  from fastapi.routing import APIRouter
@@ -159,4 +163,28 @@ def ensure_file(path: Path, content: str) -> None:
159
163
  return
160
164
  path.parent.mkdir(parents=True, exist_ok=True)
161
165
  path.write_text(content, encoding="utf-8")
162
- logger.debug("Created file %s", path)
166
+ logger.debug("Created file %s", path)
167
+
168
+
169
+ def atomic_write(target: Path, suffix: str, write_fn: Callable[[Path], None]) -> None:
170
+ """Write via a temp file on the target filesystem with EXDEV fallback."""
171
+ target.parent.mkdir(parents=True, exist_ok=True)
172
+ fd, tmp_name = tempfile.mkstemp(
173
+ dir=target.parent,
174
+ prefix=f".{target.stem}.",
175
+ suffix=suffix,
176
+ )
177
+ os.close(fd)
178
+ tmp_path = Path(tmp_name)
179
+
180
+ try:
181
+ write_fn(tmp_path)
182
+ try:
183
+ os.replace(tmp_path, target)
184
+ except OSError as exc:
185
+ if exc.errno != errno.EXDEV:
186
+ raise
187
+ shutil.copyfile(tmp_path, target)
188
+ tmp_path.unlink()
189
+ finally:
190
+ tmp_path.unlink(missing_ok=True)
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
  import logging
8
8
 
9
- from .base import ResourceDescriptor
9
+ from .base import ResourceDescriptor, atomic_write
10
10
  from .dataset_plugin import DatasetPlugin
11
11
 
12
12
  logger = logging.getLogger(__name__)
@@ -51,17 +51,14 @@ class CsvPlugin(DatasetPlugin):
51
51
 
52
52
  def _write_rows(self, resource: ResourceDescriptor, rows: list[dict[str, Any]], header: list[str]) -> None:
53
53
  """Write rows back to CSV file atomically."""
54
- # Write back atomically
55
- import tempfile
56
- import os
57
- with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, newline='', encoding='utf-8') as tmp_fh:
58
- writer = csv.writer(tmp_fh)
59
- writer.writerow(header)
60
- for row in rows:
61
- writer.writerow([row.get(col, "") for col in header])
62
- tmp_path = tmp_fh.name
54
+ def write_tmp(tmp_path: Path) -> None:
55
+ with tmp_path.open(mode="w", newline="", encoding="utf-8") as tmp_fh:
56
+ writer = csv.writer(tmp_fh)
57
+ writer.writerow(header)
58
+ for row in rows:
59
+ writer.writerow([row.get(col, "") for col in header])
63
60
 
64
- os.replace(tmp_path, resource.path)
61
+ atomic_write(resource.path, ".csv", write_tmp)
65
62
  # Invalidate cache after mutation
66
63
  invalidate_cache(str(resource.path))
67
- logger.debug("Wrote %d rows to %s", len(rows), resource.path)
64
+ logger.debug("Wrote %d rows to %s", len(rows), resource.path)
@@ -7,7 +7,7 @@ from typing import Any, Sequence
7
7
 
8
8
  from openpyxl import load_workbook
9
9
 
10
- from .base import ResourceDescriptor
10
+ from .base import ResourceDescriptor, atomic_write
11
11
  from .dataset_plugin import DatasetPlugin
12
12
 
13
13
 
@@ -103,9 +103,6 @@ class ExcelPlugin(DatasetPlugin):
103
103
  """
104
104
  logger.info(f"Writing rows to Excel worksheet: {resource.metadata.get('sub_namespace', '')}")
105
105
  sub_namespace = resource.metadata["sub_namespace"]
106
- # Write back atomically
107
- import tempfile
108
- import os
109
106
  workbook = load_workbook(resource.path)
110
107
  try:
111
108
  sheet = workbook[sub_namespace]
@@ -117,13 +114,14 @@ class ExcelPlugin(DatasetPlugin):
117
114
  for row_idx, row_data in enumerate(rows, start=2):
118
115
  for col_idx, col_name in enumerate(header):
119
116
  sheet.cell(row=row_idx, column=col_idx+1).value = row_data.get(col_name, "")
120
- with tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') as tmp_fh:
121
- workbook.save(tmp_fh.name)
122
- tmp_path = tmp_fh.name
117
+
118
+ def write_tmp(tmp_path: Path) -> None:
119
+ workbook.save(tmp_path)
120
+
121
+ atomic_write(resource.path, ".xlsx", write_tmp)
123
122
  finally:
124
123
  workbook.close()
125
124
 
126
- os.replace(tmp_path, resource.path)
127
125
  # Invalidate cache after mutation
128
126
  invalidate_cache(str(resource.path))
129
127
  logger.debug(f"Successfully wrote {len(rows)} rows to Excel file: {resource.path}")