framework-m-studio 0.2.3__py3-none-any.whl → 0.3.0__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,157 @@
1
+ """Quality assurance commands for Framework M."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import cyclopts
11
+
12
+
13
+ def get_pythonpath() -> str:
14
+ """Construct PYTHONPATH including current directory and src."""
15
+ cwd = Path.cwd()
16
+ paths = [str(cwd)]
17
+
18
+ src = cwd / "src"
19
+ if src.exists():
20
+ paths.append(str(src))
21
+
22
+ return ":".join(paths)
23
+
24
+
25
+ def build_pytest_command(
26
+ verbose: bool = False,
27
+ coverage: bool = False,
28
+ filter_expr: str | None = None,
29
+ extra_args: tuple[str, ...] = (),
30
+ ) -> list[str]:
31
+ """Build pytest command."""
32
+ cmd = [sys.executable, "-m", "pytest"]
33
+ if verbose:
34
+ cmd.append("-v")
35
+ if coverage:
36
+ cmd.append("--cov")
37
+ if filter_expr:
38
+ cmd.extend(["-k", filter_expr])
39
+ if extra_args:
40
+ cmd.extend(extra_args)
41
+ else:
42
+ cmd.append(".")
43
+ return cmd
44
+
45
+
46
+ def build_ruff_check_command(
47
+ fix: bool = False,
48
+ extra_args: tuple[str, ...] = (),
49
+ ) -> list[str]:
50
+ """Build ruff check command."""
51
+ cmd = [sys.executable, "-m", "ruff", "check"]
52
+ if fix:
53
+ cmd.append("--fix")
54
+ if extra_args:
55
+ cmd.extend(extra_args)
56
+ return cmd
57
+
58
+
59
+ def build_ruff_format_command(
60
+ check: bool = False,
61
+ extra_args: tuple[str, ...] = (),
62
+ ) -> list[str]:
63
+ """Build ruff format command."""
64
+ cmd = [sys.executable, "-m", "ruff", "format"]
65
+ if check:
66
+ cmd.append("--check")
67
+ if extra_args:
68
+ cmd.extend(extra_args)
69
+ return cmd
70
+
71
+
72
+ def build_mypy_command(
73
+ strict: bool = False,
74
+ extra_args: tuple[str, ...] = (),
75
+ ) -> list[str]:
76
+ """Build mypy command."""
77
+ cmd = [sys.executable, "-m", "mypy"]
78
+ if strict:
79
+ cmd.append("--strict")
80
+ if extra_args:
81
+ cmd.extend(extra_args)
82
+ return cmd
83
+
84
+
85
+ def _run_command(cmd: list[str]) -> None:
86
+ """Run a command and exit with its return code."""
87
+ try:
88
+ subprocess.run(cmd, check=True)
89
+ sys.exit(0)
90
+ except subprocess.CalledProcessError as e:
91
+ sys.exit(e.returncode)
92
+ except KeyboardInterrupt:
93
+ print("\nInterrupted.")
94
+ sys.exit(130)
95
+
96
+
97
+ def test_command(
98
+ path: Annotated[
99
+ str | None, cyclopts.Parameter(name="--path", help="Test path")
100
+ ] = None,
101
+ verbose: Annotated[
102
+ bool, cyclopts.Parameter(name="--verbose", help="Verbose output")
103
+ ] = False,
104
+ coverage: Annotated[
105
+ bool, cyclopts.Parameter(name="--coverage", help="Enable coverage")
106
+ ] = False,
107
+ filter: Annotated[
108
+ str | None, cyclopts.Parameter(name="--filter", help="Filter tests")
109
+ ] = None,
110
+ ) -> None:
111
+ """Run tests using pytest."""
112
+ extra = (path,) if path else ()
113
+ cmd = build_pytest_command(
114
+ verbose=verbose, coverage=coverage, filter_expr=filter, extra_args=extra
115
+ )
116
+ _run_command(cmd)
117
+
118
+
119
+ def lint_command(
120
+ fix: Annotated[bool, cyclopts.Parameter(name="--fix", help="Fix issues")] = False,
121
+ ) -> None:
122
+ """Lint code using ruff."""
123
+ cmd = build_ruff_check_command(fix=fix)
124
+ _run_command(cmd)
125
+
126
+
127
+ def format_command(
128
+ check: Annotated[
129
+ bool, cyclopts.Parameter(name="--check", help="Check only")
130
+ ] = False,
131
+ ) -> None:
132
+ """Format code using ruff."""
133
+ cmd = build_ruff_format_command(check=check)
134
+ _run_command(cmd)
135
+
136
+
137
+ def typecheck_command(
138
+ strict: Annotated[
139
+ bool, cyclopts.Parameter(name="--strict", help="Strict mode")
140
+ ] = False,
141
+ ) -> None:
142
+ """Type check code using mypy."""
143
+ cmd = build_mypy_command(strict=strict)
144
+ _run_command(cmd)
145
+
146
+
147
+ __all__ = [
148
+ "build_mypy_command",
149
+ "build_pytest_command",
150
+ "build_ruff_check_command",
151
+ "build_ruff_format_command",
152
+ "format_command",
153
+ "get_pythonpath",
154
+ "lint_command",
155
+ "test_command",
156
+ "typecheck_command",
157
+ ]
@@ -0,0 +1,159 @@
1
+ """Studio CLI Command - Admin Interface Server.
2
+
3
+ This module provides the `m studio` CLI command for running
4
+ Framework M Studio - the admin and development interface.
5
+
6
+ Usage:
7
+ m studio # Start Studio on default port (9000)
8
+ m studio --port 9001 # Custom port
9
+ m studio --reload # Enable auto-reload
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Annotated
19
+
20
+ import cyclopts
21
+
22
+ # Default Studio configuration
23
+ DEFAULT_STUDIO_PORT = 9000
24
+ DEFAULT_STUDIO_APP = "framework_m_studio.app:app"
25
+
26
+
27
+ def get_studio_app() -> str:
28
+ """Get the Studio app path.
29
+
30
+ Returns:
31
+ Studio app path in module:attribute format
32
+ """
33
+ return DEFAULT_STUDIO_APP
34
+
35
+
36
+ def get_pythonpath() -> str:
37
+ """Get PYTHONPATH with src/ directory included.
38
+
39
+ Returns:
40
+ PYTHONPATH string with src/ prepended
41
+ """
42
+ cwd = Path.cwd()
43
+ src_path = cwd / "src"
44
+
45
+ paths = []
46
+
47
+ # Add src/ if it exists
48
+ if src_path.exists():
49
+ paths.append(str(src_path))
50
+
51
+ # Add current directory
52
+ paths.append(str(cwd))
53
+
54
+ # Preserve existing PYTHONPATH
55
+ existing = os.environ.get("PYTHONPATH", "")
56
+ if existing:
57
+ paths.append(existing)
58
+
59
+ return os.pathsep.join(paths)
60
+
61
+
62
+ def build_studio_command(
63
+ host: str = "127.0.0.1",
64
+ port: int = DEFAULT_STUDIO_PORT,
65
+ reload: bool = False,
66
+ log_level: str | None = None,
67
+ ) -> list[str]:
68
+ """Build the uvicorn command line for Studio.
69
+
70
+ Args:
71
+ host: Host to bind to
72
+ port: Port to bind to
73
+ reload: Enable auto-reload
74
+ log_level: Log level (debug, info, warning, error)
75
+
76
+ Returns:
77
+ Complete command as list of strings
78
+ """
79
+ app_path = get_studio_app()
80
+ cmd = [sys.executable, "-m", "uvicorn", app_path]
81
+
82
+ cmd.extend(["--host", host])
83
+ cmd.extend(["--port", str(port)])
84
+
85
+ if reload:
86
+ cmd.append("--reload")
87
+
88
+ if log_level:
89
+ cmd.extend(["--log-level", log_level])
90
+
91
+ return cmd
92
+
93
+
94
+ def studio_command(
95
+ host: Annotated[
96
+ str,
97
+ cyclopts.Parameter(name="--host", help="Host to bind to"),
98
+ ] = "127.0.0.1",
99
+ port: Annotated[
100
+ int,
101
+ cyclopts.Parameter(name="--port", help="Port to bind to"),
102
+ ] = DEFAULT_STUDIO_PORT,
103
+ reload: Annotated[
104
+ bool,
105
+ cyclopts.Parameter(name="--reload", help="Enable auto-reload for development"),
106
+ ] = False,
107
+ log_level: Annotated[
108
+ str | None,
109
+ cyclopts.Parameter(
110
+ name="--log-level", help="Log level (debug, info, warning, error)"
111
+ ),
112
+ ] = None,
113
+ ) -> None:
114
+ """Start Framework M Studio - the admin interface.
115
+
116
+ Studio provides a web-based admin UI for:
117
+ - Browsing and editing DocTypes
118
+ - Monitoring jobs and events
119
+ - Running queries and reports
120
+
121
+ Examples:
122
+ m studio # Default port 9000
123
+ m studio --port 9001 # Custom port
124
+ m studio --reload # Dev mode with auto-reload
125
+ """
126
+ # Set up environment
127
+ env = os.environ.copy()
128
+ env["PYTHONPATH"] = get_pythonpath()
129
+
130
+ # Build command
131
+ cmd = build_studio_command(
132
+ host=host,
133
+ port=port,
134
+ reload=reload,
135
+ log_level=log_level,
136
+ )
137
+
138
+ print("Starting Framework M Studio")
139
+ print("=" * 40)
140
+ print(f" URL: http://{host}:{port}")
141
+ print(f" Reload: {reload}")
142
+ print()
143
+
144
+ # Execute uvicorn
145
+ try:
146
+ subprocess.run(cmd, env=env, check=True)
147
+ except subprocess.CalledProcessError as e:
148
+ raise SystemExit(e.returncode) from e
149
+ except KeyboardInterrupt:
150
+ print("\nStudio stopped.")
151
+
152
+
153
+ __all__ = [
154
+ "DEFAULT_STUDIO_APP",
155
+ "DEFAULT_STUDIO_PORT",
156
+ "build_studio_command",
157
+ "get_studio_app",
158
+ "studio_command",
159
+ ]
@@ -0,0 +1,50 @@
1
+ """Utility CLI commands for Framework M Studio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import cyclopts
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ def check_ipython_installed() -> bool:
14
+ """Check if IPython is installed."""
15
+ try:
16
+ import IPython # type: ignore # noqa: F401
17
+
18
+ return True
19
+ except ImportError:
20
+ return False
21
+
22
+
23
+ def console_command() -> None:
24
+ """Start an interactive console."""
25
+ if check_ipython_installed():
26
+ import IPython
27
+
28
+ IPython.embed(header="Framework M Console")
29
+ else:
30
+ import code
31
+
32
+ code.interact(banner="Framework M Console (IPython not installed)")
33
+
34
+
35
+ def routes_command(
36
+ app_path: Annotated[
37
+ str | None,
38
+ cyclopts.Parameter(name="--app", help="App path to inspect"),
39
+ ] = None,
40
+ ) -> None:
41
+ """List API routes."""
42
+ console.print("Routes: /api/v1 (Mock)")
43
+ if app_path:
44
+ console.print(f"Inspecting app: {app_path}")
45
+
46
+
47
+ __all__ = [
48
+ "console_command",
49
+ "routes_command",
50
+ ]
@@ -44,9 +44,13 @@ def _get_jinja_env() -> Environment:
44
44
 
45
45
 
46
46
  def _to_snake_case(name: str) -> str:
47
- """Convert PascalCase to snake_case."""
47
+ """Convert PascalCase or 'Spaced Words' to snake_case."""
48
+ # First, replace spaces and hyphens with underscores
49
+ name = name.replace(" ", "_").replace("-", "_")
48
50
  s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
49
- return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
51
+ result = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
52
+ # Collapse multiple underscores
53
+ return re.sub("_+", "_", result)
50
54
 
51
55
 
52
56
  def _get_test_value(field_type: str) -> str:
@@ -41,6 +41,8 @@ class FieldSchema:
41
41
  required: bool = True
42
42
  description: str | None = None
43
43
  label: str | None = None
44
+ link_doctype: str | None = None # For Link fields - which DocType to link to
45
+ table_doctype: str | None = None # For Table fields - which DocType for child table
44
46
  validators: dict[str, Any] = field(default_factory=dict)
45
47
 
46
48
 
@@ -54,6 +56,7 @@ class ConfigSchema:
54
56
  is_submittable: bool = False
55
57
  is_tree: bool = False
56
58
  track_changes: bool = True
59
+ permissions: dict[str, list[str]] | None = None
57
60
  extra: dict[str, Any] = field(default_factory=dict)
58
61
 
59
62
 
@@ -199,8 +202,58 @@ class DocTypeParserVisitor(cst.CSTVisitor):
199
202
  validators: dict[str, Any] = {}
200
203
  description: str | None = None
201
204
  label: str | None = None
205
+ link_doctype: str | None = None
206
+ table_doctype: str | None = None
207
+
208
+ # Helper to check Annotated type recursively (handles Union/BinaryOp)
209
+ def extract_annotated_metadata(
210
+ type_node: cst.BaseExpression,
211
+ ) -> tuple[str | None, str | None]:
212
+ """Extract link_doctype/table_doctype from Annotated, even in unions."""
213
+ nonlocal field_type
214
+
215
+ # Handle BinaryOp (e.g., Type | None)
216
+ if isinstance(type_node, cst.BinaryOperation):
217
+ # Check left side (usually the main type)
218
+ left_link, left_table = extract_annotated_metadata(type_node.left)
219
+ if left_link or left_table:
220
+ return left_link, left_table
221
+ # Check right side
222
+ return extract_annotated_metadata(type_node.right)
223
+
224
+ # Handle Subscript (Annotated, Optional, etc.)
225
+ if isinstance(type_node, cst.Subscript):
226
+ subscript_value = _node_to_source(type_node.value)
227
+
228
+ if subscript_value == "Annotated":
229
+ slice_elements = type_node.slice
230
+ if isinstance(slice_elements, tuple) and len(slice_elements) >= 1:
231
+ # Extract base type
232
+ first_elem = slice_elements[0]
233
+ if isinstance(first_elem.slice, cst.Index):
234
+ field_type = _node_to_source(first_elem.slice.value)
235
+ else:
236
+ field_type = _node_to_source(first_elem.slice)
237
+
238
+ # Look for Link() in metadata
239
+ for elem in slice_elements[1:]:
240
+ if isinstance(elem.slice, cst.Index):
241
+ value = elem.slice.value
242
+ else:
243
+ value = elem.slice
244
+
245
+ if isinstance(value, cst.Call):
246
+ func_name = _node_to_source(value.func)
247
+ if func_name == "Link" and value.args:
248
+ arg_value = _node_to_source(value.args[0].value)
249
+ return arg_value.strip("\"'"), None
250
+
251
+ return None, None
252
+
253
+ # Extract Link/Table metadata from type annotation
254
+ link_doctype, table_doctype = extract_annotated_metadata(field_type_raw)
202
255
 
203
- # Check for Annotated[type, Field(...)] pattern
256
+ # Also check for Annotated at top level (non-union case)
204
257
  if isinstance(field_type_raw, cst.Subscript):
205
258
  subscript_value = _node_to_source(field_type_raw.value)
206
259
  if subscript_value == "Annotated":
@@ -214,7 +267,7 @@ class DocTypeParserVisitor(cst.CSTVisitor):
214
267
  else:
215
268
  field_type = _node_to_source(first_elem.slice)
216
269
 
217
- # Look for Field() in remaining elements
270
+ # Look for Link() or Field() in remaining elements
218
271
  for elem in slice_elements[1:]:
219
272
  if isinstance(elem.slice, cst.Index):
220
273
  value = elem.slice.value
@@ -223,7 +276,12 @@ class DocTypeParserVisitor(cst.CSTVisitor):
223
276
 
224
277
  if isinstance(value, cst.Call):
225
278
  func_name = _node_to_source(value.func)
226
- if func_name == "Field":
279
+ if func_name == "Link":
280
+ # Extract linked DocType: Link("Supplier")
281
+ if value.args:
282
+ arg_value = _node_to_source(value.args[0].value)
283
+ link_doctype = arg_value.strip("\"'")
284
+ elif func_name == "Field":
227
285
  default_value, validators, description, label = (
228
286
  _parse_field_call(value)
229
287
  )
@@ -232,7 +290,6 @@ class DocTypeParserVisitor(cst.CSTVisitor):
232
290
  default_value = None
233
291
  elif default_value is not None:
234
292
  required = False
235
- break
236
293
 
237
294
  # Check for Optional type
238
295
  if "Optional" in field_type or "None" in field_type:
@@ -258,6 +315,12 @@ class DocTypeParserVisitor(cst.CSTVisitor):
258
315
  elif node.value:
259
316
  default_value = _node_to_source(node.value)
260
317
 
318
+ # Override type for special field types
319
+ if link_doctype:
320
+ field_type = "Link" # Frontend expects "Link" as the type
321
+ elif table_doctype:
322
+ field_type = "Table" # Frontend expects "Table" as the type
323
+
261
324
  field_schema = FieldSchema(
262
325
  name=field_name,
263
326
  type=field_type,
@@ -265,6 +328,8 @@ class DocTypeParserVisitor(cst.CSTVisitor):
265
328
  required=required,
266
329
  description=description,
267
330
  label=label,
331
+ link_doctype=link_doctype,
332
+ table_doctype=table_doctype,
268
333
  validators=validators,
269
334
  )
270
335
  self._current_fields.append(field_schema)
@@ -309,6 +374,9 @@ class DocTypeParserVisitor(cst.CSTVisitor):
309
374
  self._current_config.is_tree = value.lower() == "true"
310
375
  elif name == "track_changes":
311
376
  self._current_config.track_changes = value.lower() != "false"
377
+ elif name == "permissions":
378
+ # Parse permissions dict from source code string
379
+ self._current_config.permissions = _parse_permissions_dict(value)
312
380
  else:
313
381
  self._current_config.extra[name] = value
314
382
 
@@ -318,6 +386,32 @@ class DocTypeParserVisitor(cst.CSTVisitor):
318
386
  # =============================================================================
319
387
 
320
388
 
389
+ def _parse_permissions_dict(value: str) -> dict[str, list[str]] | None:
390
+ """Parse permissions dict from source code string.
391
+
392
+ Converts source code like:
393
+ {"read": ["Employee"], "write": ["Manager"]}
394
+ to an actual Python dict.
395
+
396
+ Args:
397
+ value: Source code string representing the dict
398
+
399
+ Returns:
400
+ Parsed permissions dict or None if parsing fails
401
+ """
402
+ import ast
403
+
404
+ try:
405
+ # Use ast.literal_eval to safely parse the dict
406
+ parsed = ast.literal_eval(value)
407
+ if isinstance(parsed, dict):
408
+ # Ensure all values are lists of strings
409
+ return {k: v if isinstance(v, list) else [v] for k, v in parsed.items()}
410
+ except (ValueError, SyntaxError):
411
+ pass
412
+ return None
413
+
414
+
321
415
  def _get_attribute_name(node: cst.Attribute) -> str:
322
416
  """Get full attribute name from Attribute node."""
323
417
  parts: list[str] = []
@@ -517,6 +611,8 @@ def doctype_to_dict(doctype: DocTypeSchema) -> dict[str, Any]:
517
611
  "required": f.required,
518
612
  "label": f.label,
519
613
  "description": f.description,
614
+ "link_doctype": f.link_doctype, # Include Link field metadata
615
+ "table_doctype": f.table_doctype, # Include Table field metadata
520
616
  "validators": f.validators,
521
617
  }
522
618
  for f in doctype.fields
@@ -528,6 +624,7 @@ def doctype_to_dict(doctype: DocTypeSchema) -> dict[str, Any]:
528
624
  "is_submittable": doctype.config.is_submittable,
529
625
  "is_tree": doctype.config.is_tree,
530
626
  "track_changes": doctype.config.track_changes,
627
+ "permissions": doctype.config.permissions,
531
628
  **doctype.config.extra,
532
629
  },
533
630
  "docstring": doctype.docstring,
@@ -5,7 +5,7 @@ Generated by Framework M Studio.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from typing import ClassVar
8
+ from typing import ClassVar{% if fields|selectattr('link_doctype')|list or fields|selectattr('table_doctype')|list %}, Annotated{% endif %}
9
9
 
10
10
  {% if imports %}
11
11
  {% for imp in imports %}
@@ -13,7 +13,10 @@ from typing import ClassVar
13
13
  {% endfor %}
14
14
  {% endif %}
15
15
  from pydantic import Field
16
- from framework_m.core.base import BaseDocType
16
+ from framework_m_core.domain.base_doctype import BaseDocType
17
+ {% if fields|selectattr('link_doctype')|list %}
18
+ from framework_m_core.domain.mixins import Link
19
+ {% endif %}
17
20
 
18
21
 
19
22
  class {{ name }}(BaseDocType):
@@ -24,8 +27,14 @@ class {{ name }}(BaseDocType):
24
27
  {% if fields %}
25
28
  {% for field in fields %}
26
29
  {%- set has_field_args = field.description or field.label or field.validators %}
30
+ {%- set field_type = field.type %}
31
+ {%- if field.type == 'Link' or field.link_doctype %}
32
+ {%- set field_type = 'Annotated[str, Link("' + field.link_doctype + '")]' %}
33
+ {%- elif field.type == 'Table' or field.table_doctype %}
34
+ {%- set field_type = 'list[' + field.table_doctype + ']' %}
35
+ {%- endif %}
27
36
  {% if has_field_args %}
28
- {{ field.name }}: {{ field.type }}{{ ' | None' if not field.required else '' }} = Field(
37
+ {{ field.name }}: {{ field_type }}{{ ' | None' if not field.required and 'None' not in field_type else '' }} = Field(
29
38
  {%- if field.required and (not field.default or field.default == 'None') %}...{% elif field.default and field.default != 'None' %}{{ field.default }}{% else %}None{% endif %}
30
39
  {%- if field.description %}, description="{{ field.description }}"{% endif %}
31
40
  {%- if field.label %}, label="{{ field.label }}"{% endif %}
@@ -33,16 +42,16 @@ class {{ name }}(BaseDocType):
33
42
  {%- if field.validators.min_length %}, min_length={{ field.validators.min_length }}{% endif %}
34
43
  {%- if field.validators.max_length %}, max_length={{ field.validators.max_length }}{% endif %}
35
44
  {%- if field.validators.pattern %}, pattern=r"{{ field.validators.pattern }}"{% endif %}
36
- {%- if field.validators.min_value is defined %}, ge={{ field.validators.min_value }}{% endif %}
37
- {%- if field.validators.max_value is defined %}, le={{ field.validators.max_value }}{% endif %}
38
- {%- endif %}
39
- )
45
+ {%- if field.validators.get('min_value') is not none %}, ge={{ field.validators.min_value }}{% endif %}
46
+ {%- if field.validators.get('max_value') is not none %}, le={{ field.validators.max_value }}{% endif %}
47
+ {%- endif -%}
48
+ )
40
49
  {% elif field.required and not field.default %}
41
- {{ field.name }}: {{ field.type }}
50
+ {{ field.name }}: {{ field_type }}
42
51
  {% elif field.default %}
43
- {{ field.name }}: {{ field.type }} = {{ field.default }}
52
+ {{ field.name }}: {{ field_type }} = {{ field.default }}
44
53
  {% else %}
45
- {{ field.name }}: {{ field.type }} | None = None
54
+ {{ field.name }}: {{ field_type }}{{ ' | None' if 'None' not in field_type else '' }} = None
46
55
  {% endif %}
47
56
  {% endfor %}
48
57
  {% else %}
@@ -39,9 +39,13 @@ def _get_jinja_env() -> Environment:
39
39
 
40
40
 
41
41
  def _to_snake_case(name: str) -> str:
42
- """Convert PascalCase to snake_case."""
42
+ """Convert PascalCase or 'Spaced Words' to snake_case."""
43
+ # First, replace spaces and hyphens with underscores
44
+ name = name.replace(" ", "_").replace("-", "_")
43
45
  s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
44
- return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
46
+ result = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
47
+ # Collapse multiple underscores
48
+ return re.sub("_+", "_", result)
45
49
 
46
50
 
47
51
  def _get_test_value(field_type: str, field_name: str = "") -> str: