sgalchemy 0.1.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.
Files changed (47) hide show
  1. sgalchemy/__init__.py +101 -0
  2. sgalchemy/__main__.py +8 -0
  3. sgalchemy/_cli.py +343 -0
  4. sgalchemy/_core/__init__.py +1 -0
  5. sgalchemy/_core/_async_backend.py +575 -0
  6. sgalchemy/_core/_async_engine.py +595 -0
  7. sgalchemy/_core/_compiler.py +622 -0
  8. sgalchemy/_core/_construction.py +277 -0
  9. sgalchemy/_core/_engine.py +618 -0
  10. sgalchemy/_core/_engine_common.py +192 -0
  11. sgalchemy/_core/_entity.py +55 -0
  12. sgalchemy/_core/_expressions.py +756 -0
  13. sgalchemy/_core/_options.py +240 -0
  14. sgalchemy/_core/_registry.py +1168 -0
  15. sgalchemy/_core/_result.py +41 -0
  16. sgalchemy/_core/_schema_drift.py +27 -0
  17. sgalchemy/_core/_statement.py +204 -0
  18. sgalchemy/_downstream.py +44 -0
  19. sgalchemy/_errors.py +95 -0
  20. sgalchemy/_orm/__init__.py +34 -0
  21. sgalchemy/_orm/_async_loaders.py +176 -0
  22. sgalchemy/_orm/_async_scoping.py +376 -0
  23. sgalchemy/_orm/_async_session.py +1334 -0
  24. sgalchemy/_orm/_loaders.py +333 -0
  25. sgalchemy/_orm/_scope_common.py +55 -0
  26. sgalchemy/_orm/_scoping.py +506 -0
  27. sgalchemy/_orm/_session.py +1028 -0
  28. sgalchemy/_orm/_session_common.py +51 -0
  29. sgalchemy/_orm/_state.py +718 -0
  30. sgalchemy/_orm/_uow.py +2137 -0
  31. sgalchemy/_shotgun.py +370 -0
  32. sgalchemy/_stubgen.py +381 -0
  33. sgalchemy/_stubgen_registry.py +162 -0
  34. sgalchemy/entities/__init__.py +34 -0
  35. sgalchemy/py.typed +0 -0
  36. sgalchemy/schema/__init__.py +7 -0
  37. sgalchemy/schema/_aliases.py +89 -0
  38. sgalchemy/schema/_cache.py +190 -0
  39. sgalchemy/schema/_cachedir.py +82 -0
  40. sgalchemy/schema/_model.py +132 -0
  41. sgalchemy/schema/_policy.py +34 -0
  42. sgalchemy/testing/__init__.py +227 -0
  43. sgalchemy-0.1.0.dist-info/METADATA +147 -0
  44. sgalchemy-0.1.0.dist-info/RECORD +47 -0
  45. sgalchemy-0.1.0.dist-info/WHEEL +4 -0
  46. sgalchemy-0.1.0.dist-info/entry_points.txt +2 -0
  47. sgalchemy-0.1.0.dist-info/licenses/LICENSE +21 -0
sgalchemy/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """Build reflected queries and manage explicit entity reads and writes.
2
+
3
+ Create an engine, call its `load_schema()` method, and obtain reflected classes
4
+ from `engine.entities`. Async engines require awaited schema preparation.
5
+ Queries never autoflush staged changes; call `Session.commit()` or await
6
+ `AsyncSession.commit()` explicitly.
7
+
8
+ Factories use the process default Registry unless an explicit Registry or
9
+ `bind_default=False` selects isolation. Importing this package installs the
10
+ synchronous `Entity.find()` callback; `connect()` configures its default
11
+ session. Importing the package performs no provider or schema-cache I/O.
12
+ """
13
+
14
+ from sgalchemy._core._async_engine import AsyncEngine, create_async_engine
15
+ from sgalchemy._core._engine import Engine, create_engine
16
+ from sgalchemy._core._expressions import FieldExpr, and_, func, or_, raw
17
+ from sgalchemy._core._options import defer, joinedload, lazyload, load_only, selectinload
18
+ from sgalchemy._core._registry import (
19
+ Entity,
20
+ InspectionInfo,
21
+ Registry,
22
+ default_registry,
23
+ inspect,
24
+ set_default_registry,
25
+ )
26
+ from sgalchemy._core._result import Result, Row
27
+ from sgalchemy._core._statement import Select, select
28
+ from sgalchemy._errors import (
29
+ CommitError,
30
+ CompilationError,
31
+ RefreshRequiredError,
32
+ RelationshipTargetUnavailableError,
33
+ RetiredEntityWriteError,
34
+ SchemaUnavailableError,
35
+ SgalchemyError,
36
+ StaleObjectError,
37
+ UnfetchedFieldError,
38
+ )
39
+ from sgalchemy._orm import (
40
+ AsyncScopedSession,
41
+ AsyncSession,
42
+ AsyncSessionFactory,
43
+ ScopedSession,
44
+ Session,
45
+ SessionFactory,
46
+ async_scoped_session,
47
+ async_sessionmaker,
48
+ connect,
49
+ scoped_session,
50
+ sessionmaker,
51
+ )
52
+ from sgalchemy._shotgun import AsyncShotgunLike, ShotgunLike
53
+
54
+ __all__ = [
55
+ 'AsyncEngine',
56
+ 'AsyncScopedSession',
57
+ 'AsyncSession',
58
+ 'AsyncSessionFactory',
59
+ 'AsyncShotgunLike',
60
+ 'CommitError',
61
+ 'CompilationError',
62
+ 'Engine',
63
+ 'Entity',
64
+ 'FieldExpr',
65
+ 'InspectionInfo',
66
+ 'RefreshRequiredError',
67
+ 'Registry',
68
+ 'RelationshipTargetUnavailableError',
69
+ 'Result',
70
+ 'RetiredEntityWriteError',
71
+ 'Row',
72
+ 'SchemaUnavailableError',
73
+ 'ScopedSession',
74
+ 'Select',
75
+ 'Session',
76
+ 'SessionFactory',
77
+ 'SgalchemyError',
78
+ 'ShotgunLike',
79
+ 'StaleObjectError',
80
+ 'UnfetchedFieldError',
81
+ 'and_',
82
+ 'async_scoped_session',
83
+ 'async_sessionmaker',
84
+ 'connect',
85
+ 'create_async_engine',
86
+ 'create_engine',
87
+ 'default_registry',
88
+ 'defer',
89
+ 'func',
90
+ 'inspect',
91
+ 'joinedload',
92
+ 'lazyload',
93
+ 'load_only',
94
+ 'or_',
95
+ 'raw',
96
+ 'scoped_session',
97
+ 'select',
98
+ 'selectinload',
99
+ 'sessionmaker',
100
+ 'set_default_registry',
101
+ ]
sgalchemy/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run the supported `python -m sgalchemy` command-line entry point."""
2
+
3
+ import sys
4
+
5
+ from sgalchemy._cli import main
6
+
7
+ if __name__ == '__main__':
8
+ sys.exit(main())
sgalchemy/_cli.py ADDED
@@ -0,0 +1,343 @@
1
+ """Command-line interface for schema caches and generated stubs."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import os
6
+ import re
7
+ import sys
8
+ import warnings
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ from sgalchemy import _stubgen
13
+ from sgalchemy._core._engine import Engine, create_engine
14
+ from sgalchemy._downstream import write_stdout
15
+ from sgalchemy._errors import SgalchemyError
16
+ from sgalchemy.schema._cache import SchemaCache, load_cache
17
+ from sgalchemy.schema._cachedir import cache_file
18
+
19
+ _COMMAND_GROUPS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
20
+ (
21
+ 'commands',
22
+ (
23
+ ('stubs', 'generate or check entity type stubs'),
24
+ ('schema', 'manage the cached schema'),
25
+ ),
26
+ ),
27
+ )
28
+ _COMMAND_HELP = {name: text for _title, commands in _COMMAND_GROUPS for name, text in commands}
29
+ _CONTROL_CHARACTERS = re.compile(r'[\x00-\x1f\x7f-\x9f]')
30
+
31
+ _MISSING_PROVIDER = (
32
+ 'no SG API provider is installed; install sgalchemy with the official, api, or async extra'
33
+ )
34
+ _PROVIDER_FAILURE_NAMES = frozenset(
35
+ {'HttpLib2Error', 'ProtocolError', 'ResponseError', 'ShotgunError'}
36
+ )
37
+ _PROVIDER_MODULE = 'shotgun_api3'
38
+
39
+
40
+ class _CommandError(Exception):
41
+ """Carry one anticipated failure message and its exit status."""
42
+
43
+ def __init__(self, message: str, status: int) -> None:
44
+ """Record the message and the exit status it maps to."""
45
+ super().__init__(message)
46
+ self._status = status
47
+
48
+
49
+ class _RootParser(argparse.ArgumentParser):
50
+ """Render options first and top-level commands under titled groups."""
51
+
52
+ def format_help(self, formatter: argparse.HelpFormatter | None = None) -> str:
53
+ """Return help with options first and commands in titled groups."""
54
+ if formatter is None:
55
+ formatter = self._get_formatter()
56
+ formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups)
57
+ formatter.add_text(self.description)
58
+ formatter.start_section('options')
59
+ formatter.add_arguments(self._get_optional_actions())
60
+ formatter.end_section()
61
+ for title, commands in _COMMAND_GROUPS:
62
+ formatter.start_section(title)
63
+ formatter.add_arguments(
64
+ [argparse.Action([], name, help=text) for name, text in commands]
65
+ )
66
+ formatter.end_section()
67
+ formatter.add_text(self.epilog)
68
+ return formatter.format_help()
69
+
70
+
71
+ def _build_parser() -> argparse.ArgumentParser:
72
+ """Build the command-line argument parser."""
73
+ parser = _RootParser(
74
+ prog='sgalchemy',
75
+ description='Generate typed entity stubs and manage cached schemas for SG sites.',
76
+ )
77
+ parser.add_argument('--version', action='store_true', help='show the version and exit')
78
+ commands = parser.add_subparsers(
79
+ dest='command', metavar='COMMAND', parser_class=argparse.ArgumentParser
80
+ )
81
+
82
+ stubs = commands.add_parser(
83
+ 'stubs',
84
+ help=_COMMAND_HELP['stubs'],
85
+ description='Generate or check type stubs for the entity classes in a site schema.',
86
+ epilog=(
87
+ 'With --site, the API key comes from SGALCHEMY_API_KEY or a hidden terminal prompt.'
88
+ ),
89
+ )
90
+ stubs.set_defaults(command_parser=stubs)
91
+ output = stubs.add_argument_group('output')
92
+ output.add_argument(
93
+ '-o',
94
+ '--out',
95
+ type=Path,
96
+ required=True,
97
+ metavar='DIR',
98
+ help='root directory for the generated stubs',
99
+ )
100
+ output.add_argument(
101
+ '--check',
102
+ action='store_true',
103
+ help='compare stubs with the schema without writing files',
104
+ )
105
+ source = stubs.add_argument_group('schema source')
106
+ source.add_argument(
107
+ '--from-cache',
108
+ type=Path,
109
+ metavar='FILE',
110
+ help='read the schema from a saved cache file',
111
+ )
112
+ source.add_argument('--site', metavar='URL', help='site URL to read the schema from')
113
+ source.add_argument('--script-name', metavar='NAME', help='script name to authenticate with')
114
+ content = stubs.add_argument_group('stub content')
115
+ content.add_argument(
116
+ '--no-entity-aliases',
117
+ action='store_false',
118
+ dest='entity_aliases',
119
+ help='omit display-name entity aliases',
120
+ )
121
+ content.add_argument(
122
+ '--no-field-aliases',
123
+ action='store_false',
124
+ dest='field_aliases',
125
+ help='omit field aliases with the sg_ prefix removed',
126
+ )
127
+ content.add_argument(
128
+ '--permissive',
129
+ action='store_true',
130
+ help='allow unknown attributes in generated classes',
131
+ )
132
+
133
+ schema = commands.add_parser(
134
+ 'schema',
135
+ help=_COMMAND_HELP['schema'],
136
+ description='Manage the cached schema of an SG site.',
137
+ )
138
+ schema_commands = schema.add_subparsers(
139
+ title='commands', dest='schema_command', metavar='COMMAND', required=True
140
+ )
141
+ refresh = schema_commands.add_parser(
142
+ 'refresh',
143
+ help='fetch the schema now and rewrite its cache file',
144
+ description='Fetch the site schema now and rewrite its cache file.',
145
+ epilog='The API key comes from SGALCHEMY_API_KEY or a hidden terminal prompt.',
146
+ )
147
+ refresh.set_defaults(command_parser=refresh)
148
+ refresh.add_argument(
149
+ '--site', required=True, metavar='URL', help='site URL whose schema to fetch'
150
+ )
151
+ refresh.add_argument(
152
+ '--script-name', required=True, metavar='NAME', help='script name to authenticate with'
153
+ )
154
+ return parser
155
+
156
+
157
+ def _describe_failure(error: Exception) -> str:
158
+ """Return an exception's message, or its type name when the message is empty."""
159
+ return str(error) or type(error).__name__
160
+
161
+
162
+ def _is_provider_failure(error: Exception) -> bool:
163
+ """Report whether an exception is a provider, network, or schema-cache failure."""
164
+ return isinstance(error, (OSError, SgalchemyError)) or any(
165
+ cls.__name__ in _PROVIDER_FAILURE_NAMES for cls in type(error).__mro__
166
+ )
167
+
168
+
169
+ def _load_site_schema(engine: Engine, *, force: bool) -> None:
170
+ """Load the site schema, mapping a missing provider and provider failures."""
171
+ try:
172
+ engine.load_schema(force=force)
173
+ except ModuleNotFoundError as error:
174
+ if error.name != _PROVIDER_MODULE:
175
+ raise
176
+ raise _CommandError(_MISSING_PROVIDER, 2) from error
177
+ except Exception as error:
178
+ if not _is_provider_failure(error):
179
+ raise
180
+ raise _CommandError(
181
+ f'could not load the site schema: {_describe_failure(error)}', 1
182
+ ) from error
183
+
184
+
185
+ def _read_api_key(parser: argparse.ArgumentParser) -> str:
186
+ """Read an environment secret or require a hidden terminal prompt."""
187
+ key = os.environ.get('SGALCHEMY_API_KEY')
188
+ if key:
189
+ return key
190
+ if not sys.stdin.isatty():
191
+ parser.error('set SGALCHEMY_API_KEY or run interactively to enter an API key')
192
+ try:
193
+ with warnings.catch_warnings():
194
+ warnings.simplefilter('error', getpass.GetPassWarning)
195
+ key = getpass.getpass('API key: ')
196
+ except (EOFError, getpass.GetPassWarning):
197
+ parser.error('could not read a hidden API key; set SGALCHEMY_API_KEY')
198
+ if not key:
199
+ parser.error('the entered API key is empty; set SGALCHEMY_API_KEY or enter a key')
200
+ return key
201
+
202
+
203
+ def _render_version() -> tuple[int, str]:
204
+ """Return the installed distribution version without other work."""
205
+ import importlib.metadata # noqa: PLC0415
206
+
207
+ try:
208
+ version = importlib.metadata.version('sgalchemy')
209
+ except importlib.metadata.PackageNotFoundError:
210
+ print(
211
+ 'error: installed distribution metadata for sgalchemy is unavailable',
212
+ file=sys.stderr,
213
+ )
214
+ return 2, ''
215
+ return 0, f'sgalchemy {version}\n'
216
+
217
+
218
+ def _report_error(message: str) -> None:
219
+ """Print one terminal-safe error line to standard error."""
220
+ visible = _CONTROL_CHARACTERS.sub(lambda match: f'\\x{ord(match.group()):02x}', message)
221
+ print(f'error: {visible}', file=sys.stderr)
222
+
223
+
224
+ def _site_engine(site: str, script_name: str, parser: argparse.ArgumentParser) -> Engine:
225
+ """Create an engine for one site, reporting an invalid site URL or script name."""
226
+ api_key = _read_api_key(parser)
227
+ try:
228
+ return create_engine(site, script_name=script_name, api_key=api_key)
229
+ except ValueError as error:
230
+ raise _CommandError(str(error), 2) from error
231
+
232
+
233
+ def _connected_engine(
234
+ args: argparse.Namespace,
235
+ parser: argparse.ArgumentParser,
236
+ ) -> Engine:
237
+ """Create an engine from validated CLI credentials."""
238
+ if args.site is None or args.script_name is None:
239
+ parser.error('stubs requires --from-cache or both --site and --script-name')
240
+ return _site_engine(args.site, args.script_name, parser)
241
+
242
+
243
+ def _load_schema_cache(
244
+ args: argparse.Namespace,
245
+ parser: argparse.ArgumentParser,
246
+ ) -> SchemaCache:
247
+ """Load the explicit or connection-populated schema cache."""
248
+ path = args.from_cache
249
+ if path is None:
250
+ engine = _connected_engine(args, parser)
251
+ _load_site_schema(engine, force=False)
252
+ path = cache_file(engine.site)
253
+ cache = load_cache(path)
254
+ if cache is None:
255
+ parser.error(f'could not load schema cache: {path}')
256
+ return cache
257
+
258
+
259
+ def _refresh_schema(args: argparse.Namespace, parser: argparse.ArgumentParser) -> tuple[int, str]:
260
+ """Force a schema refetch and return its cache path."""
261
+ engine = _site_engine(args.site, args.script_name, parser)
262
+ _load_site_schema(engine, force=True)
263
+ return 0, f'refreshed schema cache: {cache_file(engine.site)}\n'
264
+
265
+
266
+ def _run_stubs(
267
+ args: argparse.Namespace,
268
+ parser: argparse.ArgumentParser,
269
+ ) -> tuple[int, str]:
270
+ """Generate or check stubs against the selected schema cache."""
271
+ cache = _load_schema_cache(args, parser)
272
+ if args.check:
273
+ fresh = _stubgen.check_stubs(
274
+ cache,
275
+ args.out,
276
+ entity_aliases=args.entity_aliases,
277
+ field_aliases=args.field_aliases,
278
+ permissive=args.permissive,
279
+ )
280
+ return (0, 'stubs are fresh\n') if fresh else (1, 'stub drift detected\n')
281
+
282
+ try:
283
+ report = _stubgen.generate_stubs(
284
+ cache,
285
+ args.out,
286
+ entity_aliases=args.entity_aliases,
287
+ field_aliases=args.field_aliases,
288
+ permissive=args.permissive,
289
+ )
290
+ except (OSError, SgalchemyError) as error:
291
+ raise _CommandError(f'could not write stubs: {_describe_failure(error)}', 1) from error
292
+ lines = [f'wrote {path}' for path in report.written]
293
+ if report.skipped_aliases:
294
+ lines.append(f'aliases skipped: {", ".join(report.skipped_aliases)}')
295
+ else:
296
+ lines.append('aliases skipped: none')
297
+ return 0, '\n'.join(lines) + '\n'
298
+
299
+
300
+ def _run(arguments: list[str]) -> tuple[int, str]:
301
+ """Parse arguments, run the selected command, and report anticipated failures."""
302
+ parser = _build_parser()
303
+ args = parser.parse_args(arguments)
304
+ if args.version:
305
+ return _render_version()
306
+ if args.command is None:
307
+ parser.error('the following arguments are required: COMMAND')
308
+ handler = _run_stubs if args.command == 'stubs' else _refresh_schema
309
+ try:
310
+ result = handler(args, args.command_parser)
311
+ except _CommandError as failure:
312
+ _report_error(str(failure))
313
+ return failure._status, '' # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
314
+ return result
315
+
316
+
317
+ def main(argv: Sequence[str] | None = None) -> int:
318
+ """Run the `sgalchemy` command-line interface.
319
+
320
+ Each command finishes its work before writing output. If the reader closes
321
+ standard output, unwritten output is discarded and the exit status still
322
+ reports the command's result.
323
+
324
+ Args:
325
+ argv: Arguments to parse, or `None` to use `sys.argv`.
326
+
327
+ Returns:
328
+ The process exit status.
329
+ """
330
+ if sys.stdout is None:
331
+ # Started with standard output closed: nothing can be delivered, and the
332
+ # no text stream is available to write to.
333
+ _report_error('standard output is unavailable')
334
+ return 2
335
+ output = ''
336
+ try:
337
+ status, output = _run(sys.argv[1:] if argv is None else list(argv))
338
+ except KeyboardInterrupt:
339
+ status = 130
340
+ finally:
341
+ # Also flushes help or usage text that argparse wrote before raising SystemExit.
342
+ write_stdout(output)
343
+ return status
@@ -0,0 +1 @@
1
+ """Hold package-internal query and engine modules; import the public API from sgalchemy."""