sqlalchemyseed 2.2.0__tar.gz → 2.4.0__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.
- {sqlalchemyseed-2.2.0/src/sqlalchemyseed.egg-info → sqlalchemyseed-2.4.0}/PKG-INFO +69 -3
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/README.md +67 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/pyproject.toml +7 -3
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/__init__.py +1 -1
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/cli.py +17 -38
- sqlalchemyseed-2.4.0/src/sqlalchemyseed/loader.py +87 -0
- sqlalchemyseed-2.4.0/src/sqlalchemyseed/pytest_plugin.py +66 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0/src/sqlalchemyseed.egg-info}/PKG-INFO +69 -3
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed.egg-info/SOURCES.txt +2 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed.egg-info/entry_points.txt +3 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/tests/test_cli.py +17 -1
- sqlalchemyseed-2.4.0/tests/test_loader.py +77 -0
- sqlalchemyseed-2.4.0/tests/test_pytest_plugin.py +331 -0
- sqlalchemyseed-2.2.0/src/sqlalchemyseed/loader.py +0 -64
- sqlalchemyseed-2.2.0/tests/test_loader.py +0 -41
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/LICENSE +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/setup.cfg +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/__main__.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/_future/__init__.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/_future/seeder.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/attribute.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/constants.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/dynamic_seeder.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/errors.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/json.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/seeder.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/util.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed/validator.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed.egg-info/dependency_links.txt +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed.egg-info/requires.txt +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed.egg-info/top_level.txt +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/tests/test_json.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/tests/test_seeder.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/tests/test_temp_seeder.py +0 -0
- {sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/tests/test_validator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemyseed
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: SQLAlchemy Seeder
|
|
5
5
|
Author-email: Jedy Matt Tabasco <hello@jedymatt.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -9,13 +9,12 @@ Project-URL: Documentation, https://sqlalchemyseed.readthedocs.io/
|
|
|
9
9
|
Project-URL: Source, https://github.com/jedymatt/sqlalchemyseed
|
|
10
10
|
Project-URL: Tracker, https://github.com/jedymatt/sqlalchemyseed/issues
|
|
11
11
|
Keywords: sqlalchemy,orm,seed,seeder,json,yaml
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.10
|
|
19
18
|
Description-Content-Type: text/markdown
|
|
20
19
|
License-File: LICENSE
|
|
21
20
|
Requires-Dist: SQLAlchemy>=2.0
|
|
@@ -123,6 +122,73 @@ The same command is available as a module:
|
|
|
123
122
|
python -m sqlalchemyseed data.json --url sqlite:///app.db
|
|
124
123
|
```
|
|
125
124
|
|
|
125
|
+
## Testing with pytest
|
|
126
|
+
|
|
127
|
+
Installing `sqlalchemyseed` alongside `pytest` registers a plugin that loads
|
|
128
|
+
fixture files into a transactionally-isolated session. Provide one `engine`
|
|
129
|
+
fixture in your `conftest.py`; the plugin supplies `sqlalchemyseed_session`
|
|
130
|
+
(rolled back after every test) and a `seed` factory.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# conftest.py
|
|
134
|
+
import pytest
|
|
135
|
+
from sqlalchemy import create_engine, event
|
|
136
|
+
from sqlalchemy.pool import StaticPool
|
|
137
|
+
|
|
138
|
+
from myapp.models import Base
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture(scope="session")
|
|
142
|
+
def engine():
|
|
143
|
+
# StaticPool keeps a single in-memory connection alive so the schema you
|
|
144
|
+
# create is visible to the test session. A file-based or server database
|
|
145
|
+
# needs no such tweak — just return your usual engine.
|
|
146
|
+
engine = create_engine(
|
|
147
|
+
"sqlite://",
|
|
148
|
+
connect_args={"check_same_thread": False},
|
|
149
|
+
poolclass=StaticPool,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# SQLite only: hand transaction control to SQLAlchemy so an explicit
|
|
153
|
+
# commit() inside a test lands on a savepoint and is rolled back with the
|
|
154
|
+
# outer transaction. Left to itself the pysqlite driver commits straight to
|
|
155
|
+
# the database and the per-test rollback cannot undo it. Other databases
|
|
156
|
+
# (PostgreSQL, MySQL) need neither listener.
|
|
157
|
+
@event.listens_for(engine, "connect")
|
|
158
|
+
def _sqlite_no_driver_begin(dbapi_connection, connection_record):
|
|
159
|
+
dbapi_connection.isolation_level = None
|
|
160
|
+
|
|
161
|
+
@event.listens_for(engine, "begin")
|
|
162
|
+
def _sqlite_emit_begin(connection):
|
|
163
|
+
connection.exec_driver_sql("BEGIN")
|
|
164
|
+
|
|
165
|
+
Base.metadata.create_all(engine)
|
|
166
|
+
return engine
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
# test_people.py
|
|
171
|
+
from myapp.models import Person
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_people_are_seeded(seed, sqlalchemyseed_session):
|
|
175
|
+
seeder = seed("tests/data/people.yaml")
|
|
176
|
+
assert sqlalchemyseed_session.query(Person).count() == 2
|
|
177
|
+
assert seeder.instances[0].name == "Alice"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`seed()` accepts the same inputs as the library: `.json`, `.yaml`/`.yml`, and
|
|
181
|
+
`.csv` files. CSV is not self-describing, so pass the model:
|
|
182
|
+
`seed("people.csv", model="myapp.models.Person")`. Use `seeder="hybrid"` for the
|
|
183
|
+
`HybridSeeder`, and `ref_prefix=...` to override the relationship reference
|
|
184
|
+
prefix. Every test runs inside a transaction that is rolled back afterward, so
|
|
185
|
+
tests never see each other's rows.
|
|
186
|
+
|
|
187
|
+
> **Note:** the plugin registers fixtures named `engine`, `sqlalchemyseed_session`,
|
|
188
|
+
> and `seed`. Defining your own `engine` fixture is how you plug in your database;
|
|
189
|
+
> if you already use those names for something else, your definitions take
|
|
190
|
+
> precedence (pytest resolves conftest fixtures over plugin fixtures).
|
|
191
|
+
|
|
126
192
|
## Documentation
|
|
127
193
|
|
|
128
194
|
<https://sqlalchemyseed.readthedocs.io/>
|
|
@@ -98,6 +98,73 @@ The same command is available as a module:
|
|
|
98
98
|
python -m sqlalchemyseed data.json --url sqlite:///app.db
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
## Testing with pytest
|
|
102
|
+
|
|
103
|
+
Installing `sqlalchemyseed` alongside `pytest` registers a plugin that loads
|
|
104
|
+
fixture files into a transactionally-isolated session. Provide one `engine`
|
|
105
|
+
fixture in your `conftest.py`; the plugin supplies `sqlalchemyseed_session`
|
|
106
|
+
(rolled back after every test) and a `seed` factory.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# conftest.py
|
|
110
|
+
import pytest
|
|
111
|
+
from sqlalchemy import create_engine, event
|
|
112
|
+
from sqlalchemy.pool import StaticPool
|
|
113
|
+
|
|
114
|
+
from myapp.models import Base
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.fixture(scope="session")
|
|
118
|
+
def engine():
|
|
119
|
+
# StaticPool keeps a single in-memory connection alive so the schema you
|
|
120
|
+
# create is visible to the test session. A file-based or server database
|
|
121
|
+
# needs no such tweak — just return your usual engine.
|
|
122
|
+
engine = create_engine(
|
|
123
|
+
"sqlite://",
|
|
124
|
+
connect_args={"check_same_thread": False},
|
|
125
|
+
poolclass=StaticPool,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# SQLite only: hand transaction control to SQLAlchemy so an explicit
|
|
129
|
+
# commit() inside a test lands on a savepoint and is rolled back with the
|
|
130
|
+
# outer transaction. Left to itself the pysqlite driver commits straight to
|
|
131
|
+
# the database and the per-test rollback cannot undo it. Other databases
|
|
132
|
+
# (PostgreSQL, MySQL) need neither listener.
|
|
133
|
+
@event.listens_for(engine, "connect")
|
|
134
|
+
def _sqlite_no_driver_begin(dbapi_connection, connection_record):
|
|
135
|
+
dbapi_connection.isolation_level = None
|
|
136
|
+
|
|
137
|
+
@event.listens_for(engine, "begin")
|
|
138
|
+
def _sqlite_emit_begin(connection):
|
|
139
|
+
connection.exec_driver_sql("BEGIN")
|
|
140
|
+
|
|
141
|
+
Base.metadata.create_all(engine)
|
|
142
|
+
return engine
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# test_people.py
|
|
147
|
+
from myapp.models import Person
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_people_are_seeded(seed, sqlalchemyseed_session):
|
|
151
|
+
seeder = seed("tests/data/people.yaml")
|
|
152
|
+
assert sqlalchemyseed_session.query(Person).count() == 2
|
|
153
|
+
assert seeder.instances[0].name == "Alice"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`seed()` accepts the same inputs as the library: `.json`, `.yaml`/`.yml`, and
|
|
157
|
+
`.csv` files. CSV is not self-describing, so pass the model:
|
|
158
|
+
`seed("people.csv", model="myapp.models.Person")`. Use `seeder="hybrid"` for the
|
|
159
|
+
`HybridSeeder`, and `ref_prefix=...` to override the relationship reference
|
|
160
|
+
prefix. Every test runs inside a transaction that is rolled back afterward, so
|
|
161
|
+
tests never see each other's rows.
|
|
162
|
+
|
|
163
|
+
> **Note:** the plugin registers fixtures named `engine`, `sqlalchemyseed_session`,
|
|
164
|
+
> and `seed`. Defining your own `engine` fixture is how you plug in your database;
|
|
165
|
+
> if you already use those names for something else, your definitions take
|
|
166
|
+
> precedence (pytest resolves conftest fixtures over plugin fixtures).
|
|
167
|
+
|
|
101
168
|
## Documentation
|
|
102
169
|
|
|
103
170
|
<https://sqlalchemyseed.readthedocs.io/>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "sqlalchemyseed"
|
|
3
3
|
description = "SQLAlchemy Seeder"
|
|
4
4
|
readme = "README.md"
|
|
5
|
-
requires-python = ">=3.
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
8
8
|
authors = [
|
|
@@ -10,7 +10,6 @@ authors = [
|
|
|
10
10
|
]
|
|
11
11
|
keywords = ["sqlalchemy", "orm", "seed", "seeder", "json", "yaml"]
|
|
12
12
|
classifiers = [
|
|
13
|
-
"Programming Language :: Python :: 3.9",
|
|
14
13
|
"Programming Language :: Python :: 3.10",
|
|
15
14
|
"Programming Language :: Python :: 3.11",
|
|
16
15
|
"Programming Language :: Python :: 3.12",
|
|
@@ -30,6 +29,9 @@ yaml = [
|
|
|
30
29
|
[project.scripts]
|
|
31
30
|
sqlalchemyseed = "sqlalchemyseed.cli:main"
|
|
32
31
|
|
|
32
|
+
[project.entry-points.pytest11]
|
|
33
|
+
sqlalchemyseed = "sqlalchemyseed.pytest_plugin"
|
|
34
|
+
|
|
33
35
|
[project.urls]
|
|
34
36
|
Homepage = "https://github.com/jedymatt/sqlalchemyseed"
|
|
35
37
|
Documentation = "https://sqlalchemyseed.readthedocs.io/"
|
|
@@ -38,7 +40,9 @@ Tracker = "https://github.com/jedymatt/sqlalchemyseed/issues"
|
|
|
38
40
|
|
|
39
41
|
[dependency-groups]
|
|
40
42
|
dev = [
|
|
41
|
-
|
|
43
|
+
# 9.0.3 fixes GHSA-6w46-j5rx-g56g (tmpdir handling); it needs Python >=3.10,
|
|
44
|
+
# which is now the floor.
|
|
45
|
+
"pytest>=9.0.3",
|
|
42
46
|
"coverage>=6.2",
|
|
43
47
|
"PyYAML>=6.0",
|
|
44
48
|
]
|
|
@@ -11,13 +11,6 @@ from sqlalchemy.orm import Session
|
|
|
11
11
|
from . import loader
|
|
12
12
|
from .seeder import HybridSeeder, Seeder
|
|
13
13
|
|
|
14
|
-
_JSON_EXTENSIONS = {".json"}
|
|
15
|
-
_YAML_EXTENSIONS = {".yaml", ".yml"}
|
|
16
|
-
_CSV_EXTENSIONS = {".csv"}
|
|
17
|
-
# Only self-describing formats are auto-discovered inside a directory. CSV
|
|
18
|
-
# needs an explicit --model, so a CSV must be named as an individual file.
|
|
19
|
-
_DISCOVERABLE_EXTENSIONS = _JSON_EXTENSIONS | _YAML_EXTENSIONS
|
|
20
|
-
|
|
21
14
|
|
|
22
15
|
def build_parser() -> argparse.ArgumentParser:
|
|
23
16
|
"""Build the argument parser for the ``sqlalchemyseed`` command."""
|
|
@@ -55,6 +48,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
55
48
|
action="store_true",
|
|
56
49
|
help="seed within a transaction but roll back instead of committing",
|
|
57
50
|
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--debug",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="re-raise errors with a full traceback instead of a one-line message",
|
|
55
|
+
)
|
|
58
56
|
return parser
|
|
59
57
|
|
|
60
58
|
|
|
@@ -79,7 +77,7 @@ def _discover_directory(directory: Path) -> list:
|
|
|
79
77
|
"""Return the JSON/YAML files inside a directory, sorted by name."""
|
|
80
78
|
discovered = sorted(
|
|
81
79
|
child for child in directory.iterdir()
|
|
82
|
-
if child.suffix.lower() in
|
|
80
|
+
if child.suffix.lower() in loader.DISCOVERABLE_EXTENSIONS
|
|
83
81
|
)
|
|
84
82
|
if not discovered:
|
|
85
83
|
raise FileNotFoundError(
|
|
@@ -88,25 +86,6 @@ def _discover_directory(directory: Path) -> list:
|
|
|
88
86
|
return discovered
|
|
89
87
|
|
|
90
88
|
|
|
91
|
-
def load_file(path: Path, model=None) -> dict:
|
|
92
|
-
"""Load entities from a single data file, dispatching on its extension."""
|
|
93
|
-
suffix = path.suffix.lower()
|
|
94
|
-
if suffix in _JSON_EXTENSIONS:
|
|
95
|
-
return loader.load_entities_from_json(str(path))
|
|
96
|
-
if suffix in _YAML_EXTENSIONS:
|
|
97
|
-
return loader.load_entities_from_yaml(str(path))
|
|
98
|
-
if suffix in _CSV_EXTENSIONS:
|
|
99
|
-
return _load_csv(path, model)
|
|
100
|
-
raise ValueError(f"unsupported file type: {path}")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def _load_csv(path: Path, model) -> dict:
|
|
104
|
-
"""Load entities from a CSV file, which requires an explicit model."""
|
|
105
|
-
if model is None:
|
|
106
|
-
raise ValueError(f"CSV input requires --model to name the target class: {path}")
|
|
107
|
-
return loader.load_entities_from_csv(str(path), model)
|
|
108
|
-
|
|
109
|
-
|
|
110
89
|
def _make_seeder(name, session, ref_prefix):
|
|
111
90
|
"""Return the seeder implementation selected on the command line."""
|
|
112
91
|
if name == "hybrid":
|
|
@@ -118,7 +97,7 @@ def _seed_all(seeder, files, model) -> int:
|
|
|
118
97
|
"""Seed every file through the seeder and return the entity count."""
|
|
119
98
|
seeded = 0
|
|
120
99
|
for path in files:
|
|
121
|
-
seeder.seed(
|
|
100
|
+
seeder.seed(loader.load_path(path, model))
|
|
122
101
|
seeded += len(seeder.instances)
|
|
123
102
|
return seeded
|
|
124
103
|
|
|
@@ -141,17 +120,17 @@ def main(argv=None) -> int:
|
|
|
141
120
|
except FileNotFoundError as error:
|
|
142
121
|
parser.error(str(error))
|
|
143
122
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
123
|
+
try:
|
|
124
|
+
engine = sqlalchemy.create_engine(url)
|
|
125
|
+
with Session(engine) as session:
|
|
126
|
+
seeder = _make_seeder(args.seeder, session, args.ref_prefix)
|
|
148
127
|
seeded = _seed_all(seeder, files, args.model)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return
|
|
128
|
+
return _finish(session, seeded, len(files), args.dry_run)
|
|
129
|
+
except Exception as error: # noqa: BLE001 - top-level boundary: report any failure as exit code 1
|
|
130
|
+
if args.debug:
|
|
131
|
+
raise
|
|
132
|
+
print(f"error: {type(error).__name__}: {error}", file=sys.stderr)
|
|
133
|
+
return 1
|
|
155
134
|
|
|
156
135
|
|
|
157
136
|
def _finish(session, seeded, file_count, dry_run) -> int:
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text file loader module
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import yaml
|
|
12
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_entities_from_json(json_filepath) -> dict:
|
|
17
|
+
"""
|
|
18
|
+
Get entities from json
|
|
19
|
+
"""
|
|
20
|
+
with open(json_filepath, 'r', encoding='utf-8') as file:
|
|
21
|
+
entities = json.loads(file.read())
|
|
22
|
+
|
|
23
|
+
return entities
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_entities_from_yaml(yaml_filepath):
|
|
27
|
+
"""
|
|
28
|
+
Get entities from yaml
|
|
29
|
+
"""
|
|
30
|
+
if 'yaml' not in sys.modules:
|
|
31
|
+
raise ModuleNotFoundError(
|
|
32
|
+
'PyYAML is not installed and is required to run this function. '
|
|
33
|
+
'To use this function, py -m pip install "sqlalchemyseed[yaml]"'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
with open(yaml_filepath, 'r', encoding='utf-8') as file:
|
|
37
|
+
entities = yaml.load(file.read(), Loader=yaml.SafeLoader)
|
|
38
|
+
|
|
39
|
+
return entities
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_entities_from_csv(csv_filepath: str, model) -> dict:
|
|
43
|
+
"""Load entities from csv file
|
|
44
|
+
|
|
45
|
+
:param csv_filepath: string csv file path
|
|
46
|
+
:param model: either str or class
|
|
47
|
+
:return: dict of entities
|
|
48
|
+
"""
|
|
49
|
+
with open(csv_filepath, 'r', encoding='utf-8') as file:
|
|
50
|
+
source_data = list(
|
|
51
|
+
map(dict, csv.DictReader(file, skipinitialspace=True)))
|
|
52
|
+
if isinstance(model, str):
|
|
53
|
+
model_name = model
|
|
54
|
+
else:
|
|
55
|
+
model_name = '.'.join([model.__module__, model.__name__])
|
|
56
|
+
|
|
57
|
+
entities = {'model': model_name, 'data': source_data}
|
|
58
|
+
|
|
59
|
+
return entities
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_JSON_EXTENSIONS = {".json"}
|
|
63
|
+
_YAML_EXTENSIONS = {".yaml", ".yml"}
|
|
64
|
+
_CSV_EXTENSIONS = {".csv"}
|
|
65
|
+
# Formats that are self-describing (carry their own model) and so can be
|
|
66
|
+
# auto-discovered inside a directory. CSV needs an explicit model.
|
|
67
|
+
DISCOVERABLE_EXTENSIONS = _JSON_EXTENSIONS | _YAML_EXTENSIONS
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_path(path, model=None) -> dict:
|
|
71
|
+
"""Load entities from a single data file, dispatching on its extension."""
|
|
72
|
+
path = Path(path)
|
|
73
|
+
suffix = path.suffix.lower()
|
|
74
|
+
if suffix in _JSON_EXTENSIONS:
|
|
75
|
+
return load_entities_from_json(str(path))
|
|
76
|
+
if suffix in _YAML_EXTENSIONS:
|
|
77
|
+
return load_entities_from_yaml(str(path))
|
|
78
|
+
if suffix in _CSV_EXTENSIONS:
|
|
79
|
+
return _load_csv(path, model)
|
|
80
|
+
raise ValueError(f"unsupported file type: {path}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _load_csv(path, model) -> dict:
|
|
84
|
+
"""Load entities from a CSV file, which requires an explicit model."""
|
|
85
|
+
if model is None:
|
|
86
|
+
raise ValueError(f"CSV input requires a model to name the target class: {path}")
|
|
87
|
+
return load_entities_from_csv(str(path), model)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""pytest plugin: file-based SQLAlchemy fixtures with per-test rollback.
|
|
2
|
+
|
|
3
|
+
Registered via the ``pytest11`` entry point. Defines fixtures only — importing
|
|
4
|
+
this module has no side effects, and it is never imported by the package's
|
|
5
|
+
``__init__`` so production imports never pull in pytest.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from .loader import load_path
|
|
12
|
+
from .seeder import HybridSeeder, Seeder
|
|
13
|
+
|
|
14
|
+
_MISSING_ENGINE_MESSAGE = (
|
|
15
|
+
"sqlalchemyseed's pytest plugin requires an 'engine' fixture. Define one in "
|
|
16
|
+
"your conftest.py that returns a SQLAlchemy Engine with your schema created, "
|
|
17
|
+
"e.g.:\n\n"
|
|
18
|
+
" @pytest.fixture(scope='session')\n"
|
|
19
|
+
" def engine():\n"
|
|
20
|
+
" engine = create_engine('sqlite:///test.db')\n"
|
|
21
|
+
" Base.metadata.create_all(engine)\n"
|
|
22
|
+
" return engine\n\n"
|
|
23
|
+
"For an in-memory SQLite database, and to have an explicit commit() rolled "
|
|
24
|
+
"back per test, see the docs for the full engine setup.\n"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def engine():
|
|
30
|
+
"""Placeholder the user overrides in their conftest with a real Engine."""
|
|
31
|
+
raise RuntimeError(_MISSING_ENGINE_MESSAGE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def sqlalchemyseed_session(engine):
|
|
36
|
+
"""A Session in an open transaction that is rolled back after the test."""
|
|
37
|
+
connection = engine.connect()
|
|
38
|
+
transaction = connection.begin()
|
|
39
|
+
session = Session(bind=connection, join_transaction_mode="create_savepoint")
|
|
40
|
+
try:
|
|
41
|
+
yield session
|
|
42
|
+
finally:
|
|
43
|
+
# Nested so connection.close() always runs even if an earlier step
|
|
44
|
+
# raises — a leaked connection would otherwise exhaust the pool and
|
|
45
|
+
# surface as a failure in an unrelated later test.
|
|
46
|
+
try:
|
|
47
|
+
session.close()
|
|
48
|
+
finally:
|
|
49
|
+
try:
|
|
50
|
+
transaction.rollback()
|
|
51
|
+
finally:
|
|
52
|
+
connection.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def seed(sqlalchemyseed_session):
|
|
57
|
+
"""Return a callable that seeds a data file into the test session."""
|
|
58
|
+
def _seed(path, *, model=None, seeder="basic", ref_prefix="!"):
|
|
59
|
+
entities = load_path(path, model)
|
|
60
|
+
seeder_cls = HybridSeeder if seeder == "hybrid" else Seeder
|
|
61
|
+
instance = seeder_cls(sqlalchemyseed_session, ref_prefix=ref_prefix)
|
|
62
|
+
instance.seed(entities)
|
|
63
|
+
sqlalchemyseed_session.flush()
|
|
64
|
+
return instance
|
|
65
|
+
|
|
66
|
+
return _seed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemyseed
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: SQLAlchemy Seeder
|
|
5
5
|
Author-email: Jedy Matt Tabasco <hello@jedymatt.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -9,13 +9,12 @@ Project-URL: Documentation, https://sqlalchemyseed.readthedocs.io/
|
|
|
9
9
|
Project-URL: Source, https://github.com/jedymatt/sqlalchemyseed
|
|
10
10
|
Project-URL: Tracker, https://github.com/jedymatt/sqlalchemyseed/issues
|
|
11
11
|
Keywords: sqlalchemy,orm,seed,seeder,json,yaml
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.10
|
|
19
18
|
Description-Content-Type: text/markdown
|
|
20
19
|
License-File: LICENSE
|
|
21
20
|
Requires-Dist: SQLAlchemy>=2.0
|
|
@@ -123,6 +122,73 @@ The same command is available as a module:
|
|
|
123
122
|
python -m sqlalchemyseed data.json --url sqlite:///app.db
|
|
124
123
|
```
|
|
125
124
|
|
|
125
|
+
## Testing with pytest
|
|
126
|
+
|
|
127
|
+
Installing `sqlalchemyseed` alongside `pytest` registers a plugin that loads
|
|
128
|
+
fixture files into a transactionally-isolated session. Provide one `engine`
|
|
129
|
+
fixture in your `conftest.py`; the plugin supplies `sqlalchemyseed_session`
|
|
130
|
+
(rolled back after every test) and a `seed` factory.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# conftest.py
|
|
134
|
+
import pytest
|
|
135
|
+
from sqlalchemy import create_engine, event
|
|
136
|
+
from sqlalchemy.pool import StaticPool
|
|
137
|
+
|
|
138
|
+
from myapp.models import Base
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture(scope="session")
|
|
142
|
+
def engine():
|
|
143
|
+
# StaticPool keeps a single in-memory connection alive so the schema you
|
|
144
|
+
# create is visible to the test session. A file-based or server database
|
|
145
|
+
# needs no such tweak — just return your usual engine.
|
|
146
|
+
engine = create_engine(
|
|
147
|
+
"sqlite://",
|
|
148
|
+
connect_args={"check_same_thread": False},
|
|
149
|
+
poolclass=StaticPool,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# SQLite only: hand transaction control to SQLAlchemy so an explicit
|
|
153
|
+
# commit() inside a test lands on a savepoint and is rolled back with the
|
|
154
|
+
# outer transaction. Left to itself the pysqlite driver commits straight to
|
|
155
|
+
# the database and the per-test rollback cannot undo it. Other databases
|
|
156
|
+
# (PostgreSQL, MySQL) need neither listener.
|
|
157
|
+
@event.listens_for(engine, "connect")
|
|
158
|
+
def _sqlite_no_driver_begin(dbapi_connection, connection_record):
|
|
159
|
+
dbapi_connection.isolation_level = None
|
|
160
|
+
|
|
161
|
+
@event.listens_for(engine, "begin")
|
|
162
|
+
def _sqlite_emit_begin(connection):
|
|
163
|
+
connection.exec_driver_sql("BEGIN")
|
|
164
|
+
|
|
165
|
+
Base.metadata.create_all(engine)
|
|
166
|
+
return engine
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
# test_people.py
|
|
171
|
+
from myapp.models import Person
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_people_are_seeded(seed, sqlalchemyseed_session):
|
|
175
|
+
seeder = seed("tests/data/people.yaml")
|
|
176
|
+
assert sqlalchemyseed_session.query(Person).count() == 2
|
|
177
|
+
assert seeder.instances[0].name == "Alice"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`seed()` accepts the same inputs as the library: `.json`, `.yaml`/`.yml`, and
|
|
181
|
+
`.csv` files. CSV is not self-describing, so pass the model:
|
|
182
|
+
`seed("people.csv", model="myapp.models.Person")`. Use `seeder="hybrid"` for the
|
|
183
|
+
`HybridSeeder`, and `ref_prefix=...` to override the relationship reference
|
|
184
|
+
prefix. Every test runs inside a transaction that is rolled back afterward, so
|
|
185
|
+
tests never see each other's rows.
|
|
186
|
+
|
|
187
|
+
> **Note:** the plugin registers fixtures named `engine`, `sqlalchemyseed_session`,
|
|
188
|
+
> and `seed`. Defining your own `engine` fixture is how you plug in your database;
|
|
189
|
+
> if you already use those names for something else, your definitions take
|
|
190
|
+
> precedence (pytest resolves conftest fixtures over plugin fixtures).
|
|
191
|
+
|
|
126
192
|
## Documentation
|
|
127
193
|
|
|
128
194
|
<https://sqlalchemyseed.readthedocs.io/>
|
|
@@ -10,6 +10,7 @@ src/sqlalchemyseed/dynamic_seeder.py
|
|
|
10
10
|
src/sqlalchemyseed/errors.py
|
|
11
11
|
src/sqlalchemyseed/json.py
|
|
12
12
|
src/sqlalchemyseed/loader.py
|
|
13
|
+
src/sqlalchemyseed/pytest_plugin.py
|
|
13
14
|
src/sqlalchemyseed/seeder.py
|
|
14
15
|
src/sqlalchemyseed/util.py
|
|
15
16
|
src/sqlalchemyseed/validator.py
|
|
@@ -24,6 +25,7 @@ src/sqlalchemyseed/_future/seeder.py
|
|
|
24
25
|
tests/test_cli.py
|
|
25
26
|
tests/test_json.py
|
|
26
27
|
tests/test_loader.py
|
|
28
|
+
tests/test_pytest_plugin.py
|
|
27
29
|
tests/test_seeder.py
|
|
28
30
|
tests/test_temp_seeder.py
|
|
29
31
|
tests/test_validator.py
|
|
@@ -68,7 +68,7 @@ def test_csv_without_model_fails(tmp_path, db_url, capsys):
|
|
|
68
68
|
csv_file.write_text("name\nDave\n", encoding="utf-8")
|
|
69
69
|
|
|
70
70
|
assert cli.main([str(csv_file), "--url", db_url]) == 1
|
|
71
|
-
assert "requires
|
|
71
|
+
assert "requires a model" in capsys.readouterr().err
|
|
72
72
|
assert count_persons(db_url) == 0
|
|
73
73
|
|
|
74
74
|
|
|
@@ -131,3 +131,19 @@ def test_unsupported_file_type(tmp_path, db_url):
|
|
|
131
131
|
bad_file.write_text("nope", encoding="utf-8")
|
|
132
132
|
|
|
133
133
|
assert cli.main([str(bad_file), "--url", db_url]) == 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_error_message_includes_exception_type(tmp_path, db_url, capsys):
|
|
137
|
+
bad_file = tmp_path / "people.txt"
|
|
138
|
+
bad_file.write_text("nope", encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
assert cli.main([str(bad_file), "--url", db_url]) == 1
|
|
141
|
+
assert "ValueError" in capsys.readouterr().err
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_debug_flag_reraises_with_traceback(tmp_path, db_url):
|
|
145
|
+
bad_file = tmp_path / "people.txt"
|
|
146
|
+
bad_file.write_text("nope", encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
with pytest.raises(ValueError):
|
|
149
|
+
cli.main([str(bad_file), "--url", db_url, "--debug"])
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from sqlalchemyseed import loader
|
|
6
|
+
from sqlalchemyseed import load_entities_from_json
|
|
7
|
+
from sqlalchemyseed import load_entities_from_yaml
|
|
8
|
+
from sqlalchemyseed import load_entities_from_csv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_load_path_reads_json(tmp_path):
|
|
12
|
+
data_file = tmp_path / "d.json"
|
|
13
|
+
data_file.write_text('{"model": "m.M", "data": []}', encoding="utf-8")
|
|
14
|
+
assert loader.load_path(data_file) == {"model": "m.M", "data": []}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_load_path_reads_yaml(tmp_path):
|
|
18
|
+
data_file = tmp_path / "d.yml"
|
|
19
|
+
data_file.write_text("model: m.M\ndata: []\n", encoding="utf-8")
|
|
20
|
+
assert loader.load_path(data_file) == {"model": "m.M", "data": []}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_load_path_rejects_unknown_extension(tmp_path):
|
|
24
|
+
bad = tmp_path / "d.txt"
|
|
25
|
+
bad.write_text("nope", encoding="utf-8")
|
|
26
|
+
with pytest.raises(ValueError):
|
|
27
|
+
loader.load_path(bad)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_load_path_csv_requires_model(tmp_path):
|
|
31
|
+
csv_file = tmp_path / "d.csv"
|
|
32
|
+
csv_file.write_text("name\nAlice\n", encoding="utf-8")
|
|
33
|
+
with pytest.raises(ValueError):
|
|
34
|
+
loader.load_path(csv_file)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_discoverable_extensions_are_json_and_yaml():
|
|
38
|
+
assert loader.DISCOVERABLE_EXTENSIONS == {".json", ".yaml", ".yml"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestLoader(unittest.TestCase):
|
|
42
|
+
def test_load_entities_from_json(self):
|
|
43
|
+
entities = load_entities_from_json('tests/res/data.json')
|
|
44
|
+
self.assertEqual(len(entities), 6)
|
|
45
|
+
|
|
46
|
+
def test_load_entities_from_json_file_not_found(self):
|
|
47
|
+
self.assertRaises(FileNotFoundError,
|
|
48
|
+
lambda: load_entities_from_json('tests/res/non-existent-file'))
|
|
49
|
+
|
|
50
|
+
def test_load_entities_from_yaml(self):
|
|
51
|
+
entities = load_entities_from_yaml('tests/res/data.yml')
|
|
52
|
+
self.assertEqual(len(entities), 2)
|
|
53
|
+
|
|
54
|
+
def test_load_entities_from_yaml_file_not_found(self):
|
|
55
|
+
self.assertRaises(FileNotFoundError,
|
|
56
|
+
lambda: load_entities_from_yaml('tests/res/non-existent-file'))
|
|
57
|
+
|
|
58
|
+
def test_load_entities_from_csv_input_class(self):
|
|
59
|
+
from tests.models import Company
|
|
60
|
+
entities = load_entities_from_csv(
|
|
61
|
+
'tests/res/companies.csv', Company)
|
|
62
|
+
self.assertEqual(len(entities['data']), 3)
|
|
63
|
+
|
|
64
|
+
def test_load_entities_from_csv_input_model_string(self):
|
|
65
|
+
self.assertIsNotNone(load_entities_from_csv(
|
|
66
|
+
'tests/res/companies.csv', "tests.models.Company"))
|
|
67
|
+
|
|
68
|
+
def test_loader_yaml_not_installed(self):
|
|
69
|
+
yaml_module = loader.sys.modules.pop('yaml')
|
|
70
|
+
try:
|
|
71
|
+
self.assertRaises(
|
|
72
|
+
ModuleNotFoundError,
|
|
73
|
+
lambda: load_entities_from_yaml('tests/res/data.yml')
|
|
74
|
+
)
|
|
75
|
+
finally:
|
|
76
|
+
loader.sys.modules['yaml'] = yaml_module
|
|
77
|
+
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Tests for the bundled pytest plugin, exercised via the pytester fixture."""
|
|
2
|
+
|
|
3
|
+
# A self-contained conftest for the inner pytest runs. StaticPool keeps a
|
|
4
|
+
# single in-memory SQLite connection alive so create_all and the session share
|
|
5
|
+
# one database.
|
|
6
|
+
CONFTEST = '''
|
|
7
|
+
import pytest
|
|
8
|
+
from sqlalchemy import create_engine, event
|
|
9
|
+
from sqlalchemy.pool import StaticPool
|
|
10
|
+
|
|
11
|
+
from models import Base
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="session")
|
|
15
|
+
def engine():
|
|
16
|
+
eng = create_engine(
|
|
17
|
+
"sqlite://",
|
|
18
|
+
connect_args={"check_same_thread": False},
|
|
19
|
+
poolclass=StaticPool,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Hand transaction control to SQLAlchemy so an explicit commit() inside a
|
|
23
|
+
# test lands on a savepoint and is rolled back with the outer transaction.
|
|
24
|
+
# Left to itself the pysqlite driver commits straight to the database and
|
|
25
|
+
# the per-test rollback cannot undo it.
|
|
26
|
+
@event.listens_for(eng, "connect")
|
|
27
|
+
def _sqlite_no_driver_begin(dbapi_connection, connection_record):
|
|
28
|
+
dbapi_connection.isolation_level = None
|
|
29
|
+
|
|
30
|
+
@event.listens_for(eng, "begin")
|
|
31
|
+
def _sqlite_emit_begin(connection):
|
|
32
|
+
connection.exec_driver_sql("BEGIN")
|
|
33
|
+
|
|
34
|
+
Base.metadata.create_all(eng)
|
|
35
|
+
return eng
|
|
36
|
+
'''
|
|
37
|
+
|
|
38
|
+
MODELS = '''
|
|
39
|
+
from sqlalchemy import Column, ForeignKey, Integer, String
|
|
40
|
+
from sqlalchemy.orm import declarative_base, relationship
|
|
41
|
+
|
|
42
|
+
Base = declarative_base()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Person(Base):
|
|
46
|
+
__tablename__ = "persons"
|
|
47
|
+
id = Column(Integer, primary_key=True)
|
|
48
|
+
name = Column(String(50))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Company(Base):
|
|
52
|
+
__tablename__ = "companies"
|
|
53
|
+
id = Column(Integer, primary_key=True)
|
|
54
|
+
name = Column(String(50))
|
|
55
|
+
employees = relationship("Employee", back_populates="company")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Employee(Base):
|
|
59
|
+
__tablename__ = "employees"
|
|
60
|
+
id = Column(Integer, primary_key=True)
|
|
61
|
+
name = Column(String(50))
|
|
62
|
+
company_id = Column(Integer, ForeignKey("companies.id"))
|
|
63
|
+
company = relationship("Company", back_populates="employees")
|
|
64
|
+
'''
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _scaffold(pytester):
|
|
68
|
+
"""Write the shared conftest + models module into the inner project."""
|
|
69
|
+
pytester.makeconftest(CONFTEST)
|
|
70
|
+
pytester.makepyfile(models=MODELS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_seed_json(pytester):
|
|
74
|
+
_scaffold(pytester)
|
|
75
|
+
pytester.makefile(
|
|
76
|
+
".json",
|
|
77
|
+
people='{"model": "models.Person", "data": [{"name": "Alice"}, {"name": "Bob"}]}',
|
|
78
|
+
)
|
|
79
|
+
pytester.makepyfile(
|
|
80
|
+
test_seed_json='''
|
|
81
|
+
from models import Person
|
|
82
|
+
|
|
83
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
84
|
+
seeder = seed("people.json")
|
|
85
|
+
assert sqlalchemyseed_session.query(Person).count() == 2
|
|
86
|
+
assert seeder.instances[0].name == "Alice"
|
|
87
|
+
'''
|
|
88
|
+
)
|
|
89
|
+
result = pytester.runpytest()
|
|
90
|
+
result.assert_outcomes(passed=1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_seed_yaml(pytester):
|
|
94
|
+
_scaffold(pytester)
|
|
95
|
+
pytester.makefile(
|
|
96
|
+
".yaml",
|
|
97
|
+
people="model: models.Person\ndata:\n - name: Carol\n",
|
|
98
|
+
)
|
|
99
|
+
pytester.makepyfile(
|
|
100
|
+
test_seed_yaml='''
|
|
101
|
+
from models import Person
|
|
102
|
+
|
|
103
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
104
|
+
seed("people.yaml")
|
|
105
|
+
assert sqlalchemyseed_session.query(Person).count() == 1
|
|
106
|
+
'''
|
|
107
|
+
)
|
|
108
|
+
result = pytester.runpytest()
|
|
109
|
+
result.assert_outcomes(passed=1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_seed_csv_with_model(pytester):
|
|
113
|
+
_scaffold(pytester)
|
|
114
|
+
pytester.makefile(".csv", people="name\nDave\nErin\n")
|
|
115
|
+
pytester.makepyfile(
|
|
116
|
+
test_seed_csv='''
|
|
117
|
+
from models import Person
|
|
118
|
+
|
|
119
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
120
|
+
seed("people.csv", model="models.Person")
|
|
121
|
+
assert sqlalchemyseed_session.query(Person).count() == 2
|
|
122
|
+
'''
|
|
123
|
+
)
|
|
124
|
+
result = pytester.runpytest()
|
|
125
|
+
result.assert_outcomes(passed=1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_hybrid_seeder(pytester):
|
|
129
|
+
_scaffold(pytester)
|
|
130
|
+
pytester.makefile(
|
|
131
|
+
".json",
|
|
132
|
+
people='{"model": "models.Person", "data": [{"name": "Alice"}]}',
|
|
133
|
+
)
|
|
134
|
+
pytester.makepyfile(
|
|
135
|
+
test_hybrid='''
|
|
136
|
+
from models import Person
|
|
137
|
+
|
|
138
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
139
|
+
seeder = seed("people.json", seeder="hybrid")
|
|
140
|
+
assert sqlalchemyseed_session.query(Person).count() == 1
|
|
141
|
+
assert seeder.instances[0].name == "Alice"
|
|
142
|
+
'''
|
|
143
|
+
)
|
|
144
|
+
result = pytester.runpytest()
|
|
145
|
+
result.assert_outcomes(passed=1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_rollback_isolation(pytester):
|
|
149
|
+
_scaffold(pytester)
|
|
150
|
+
pytester.makefile(
|
|
151
|
+
".json",
|
|
152
|
+
people='{"model": "models.Person", "data": [{"name": "Alice"}, {"name": "Bob"}]}',
|
|
153
|
+
)
|
|
154
|
+
pytester.makepyfile(
|
|
155
|
+
test_isolation='''
|
|
156
|
+
from models import Person
|
|
157
|
+
|
|
158
|
+
def test_first_seeds(seed, sqlalchemyseed_session):
|
|
159
|
+
seed("people.json")
|
|
160
|
+
assert sqlalchemyseed_session.query(Person).count() == 2
|
|
161
|
+
|
|
162
|
+
def test_second_sees_empty_db(sqlalchemyseed_session):
|
|
163
|
+
# The prior test's rows were rolled back with its transaction.
|
|
164
|
+
assert sqlalchemyseed_session.query(Person).count() == 0
|
|
165
|
+
'''
|
|
166
|
+
)
|
|
167
|
+
result = pytester.runpytest()
|
|
168
|
+
result.assert_outcomes(passed=2)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_committed_writes_still_roll_back(pytester):
|
|
172
|
+
# The point of join_transaction_mode="create_savepoint": even an explicit
|
|
173
|
+
# commit() lands on a savepoint inside the outer transaction and is unwound
|
|
174
|
+
# by the teardown rollback. This pins the documented contract that the
|
|
175
|
+
# flush-only seed path (test_rollback_isolation) does not exercise.
|
|
176
|
+
_scaffold(pytester)
|
|
177
|
+
pytester.makefile(
|
|
178
|
+
".json",
|
|
179
|
+
people='{"model": "models.Person", "data": [{"name": "Alice"}]}',
|
|
180
|
+
)
|
|
181
|
+
pytester.makepyfile(
|
|
182
|
+
test_commit_isolation='''
|
|
183
|
+
from models import Person
|
|
184
|
+
|
|
185
|
+
def test_first_commits(seed, sqlalchemyseed_session):
|
|
186
|
+
seed("people.json")
|
|
187
|
+
sqlalchemyseed_session.commit()
|
|
188
|
+
assert sqlalchemyseed_session.query(Person).count() == 1
|
|
189
|
+
|
|
190
|
+
def test_second_sees_empty_db(sqlalchemyseed_session):
|
|
191
|
+
# The committed row was still rolled back with the outer transaction.
|
|
192
|
+
assert sqlalchemyseed_session.query(Person).count() == 0
|
|
193
|
+
'''
|
|
194
|
+
)
|
|
195
|
+
result = pytester.runpytest()
|
|
196
|
+
result.assert_outcomes(passed=2)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_reference_resolution_default_prefix(pytester):
|
|
200
|
+
# A "!"-prefixed key is a relationship reference; the seeder wires the
|
|
201
|
+
# nested Company onto the Employee. Guards the default ref_prefix.
|
|
202
|
+
_scaffold(pytester)
|
|
203
|
+
pytester.makefile(
|
|
204
|
+
".json",
|
|
205
|
+
employee=(
|
|
206
|
+
'{"model": "models.Employee", "data": {"name": "Juan", '
|
|
207
|
+
'"!company": {"model": "models.Company", "data": {"name": "Acme"}}}}'
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
pytester.makepyfile(
|
|
211
|
+
test_reference='''
|
|
212
|
+
from models import Company, Employee
|
|
213
|
+
|
|
214
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
215
|
+
seed("employee.json")
|
|
216
|
+
employee = sqlalchemyseed_session.query(Employee).one()
|
|
217
|
+
assert employee.company.name == "Acme"
|
|
218
|
+
assert sqlalchemyseed_session.query(Company).count() == 1
|
|
219
|
+
'''
|
|
220
|
+
)
|
|
221
|
+
result = pytester.runpytest()
|
|
222
|
+
result.assert_outcomes(passed=1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_custom_ref_prefix(pytester):
|
|
226
|
+
# A non-default prefix ("@") must be threaded through to the seeder so the
|
|
227
|
+
# "@company" key is treated as a reference rather than a column.
|
|
228
|
+
_scaffold(pytester)
|
|
229
|
+
pytester.makefile(
|
|
230
|
+
".json",
|
|
231
|
+
employee=(
|
|
232
|
+
'{"model": "models.Employee", "data": {"name": "Mia", '
|
|
233
|
+
'"@company": {"model": "models.Company", "data": {"name": "Globex"}}}}'
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
pytester.makepyfile(
|
|
237
|
+
test_prefix='''
|
|
238
|
+
from models import Employee
|
|
239
|
+
|
|
240
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
241
|
+
seed("employee.json", ref_prefix="@")
|
|
242
|
+
employee = sqlalchemyseed_session.query(Employee).one()
|
|
243
|
+
assert employee.company.name == "Globex"
|
|
244
|
+
'''
|
|
245
|
+
)
|
|
246
|
+
result = pytester.runpytest()
|
|
247
|
+
result.assert_outcomes(passed=1)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_multiple_seed_calls_accumulate(pytester):
|
|
251
|
+
# Seeding several files in one test is a first-class use case; each call
|
|
252
|
+
# builds a fresh seeder on the shared session and flushes.
|
|
253
|
+
_scaffold(pytester)
|
|
254
|
+
pytester.makefile(
|
|
255
|
+
".json",
|
|
256
|
+
alice='{"model": "models.Person", "data": [{"name": "Alice"}]}',
|
|
257
|
+
others='{"model": "models.Person", "data": [{"name": "Bob"}, {"name": "Carol"}]}',
|
|
258
|
+
)
|
|
259
|
+
pytester.makepyfile(
|
|
260
|
+
test_multi='''
|
|
261
|
+
from models import Person
|
|
262
|
+
|
|
263
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
264
|
+
seed("alice.json")
|
|
265
|
+
seed("others.json")
|
|
266
|
+
assert sqlalchemyseed_session.query(Person).count() == 3
|
|
267
|
+
'''
|
|
268
|
+
)
|
|
269
|
+
result = pytester.runpytest()
|
|
270
|
+
result.assert_outcomes(passed=1)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_missing_engine_gives_actionable_error(pytester):
|
|
274
|
+
# Deliberately no conftest / engine fixture.
|
|
275
|
+
pytester.makepyfile(
|
|
276
|
+
test_no_engine='''
|
|
277
|
+
def test_it(seed):
|
|
278
|
+
pass
|
|
279
|
+
'''
|
|
280
|
+
)
|
|
281
|
+
result = pytester.runpytest()
|
|
282
|
+
result.assert_outcomes(errors=1)
|
|
283
|
+
result.stdout.fnmatch_lines(["*requires an 'engine' fixture*"])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_user_can_override_session(pytester):
|
|
287
|
+
_scaffold(pytester)
|
|
288
|
+
pytester.makefile(
|
|
289
|
+
".json",
|
|
290
|
+
people='{"model": "models.Person", "data": [{"name": "Zoe"}]}',
|
|
291
|
+
)
|
|
292
|
+
# Override the plugin's session fixture; seed must use the override.
|
|
293
|
+
# A minimal session suffices here — this test proves override precedence,
|
|
294
|
+
# not the production fixture's transaction handling.
|
|
295
|
+
pytester.makepyfile(
|
|
296
|
+
test_override='''
|
|
297
|
+
import pytest
|
|
298
|
+
from sqlalchemy.orm import Session
|
|
299
|
+
from models import Person
|
|
300
|
+
|
|
301
|
+
@pytest.fixture
|
|
302
|
+
def sqlalchemyseed_session(engine):
|
|
303
|
+
session = Session(bind=engine)
|
|
304
|
+
session.info["overridden"] = True
|
|
305
|
+
yield session
|
|
306
|
+
session.close()
|
|
307
|
+
|
|
308
|
+
def test_it(seed, sqlalchemyseed_session):
|
|
309
|
+
seed("people.json")
|
|
310
|
+
assert sqlalchemyseed_session.info.get("overridden") is True
|
|
311
|
+
assert sqlalchemyseed_session.query(Person).count() == 1
|
|
312
|
+
'''
|
|
313
|
+
)
|
|
314
|
+
result = pytester.runpytest()
|
|
315
|
+
result.assert_outcomes(passed=1)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_unsupported_file_type_fails_loudly(pytester):
|
|
319
|
+
_scaffold(pytester)
|
|
320
|
+
pytester.makefile(".txt", data="nope")
|
|
321
|
+
pytester.makepyfile(
|
|
322
|
+
test_unsupported='''
|
|
323
|
+
import pytest
|
|
324
|
+
|
|
325
|
+
def test_it(seed):
|
|
326
|
+
with pytest.raises(ValueError):
|
|
327
|
+
seed("data.txt")
|
|
328
|
+
'''
|
|
329
|
+
)
|
|
330
|
+
result = pytester.runpytest()
|
|
331
|
+
result.assert_outcomes(passed=1)
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Text file loader module
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import csv
|
|
6
|
-
import json
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
try:
|
|
10
|
-
import yaml
|
|
11
|
-
except ModuleNotFoundError: # pragma: no cover
|
|
12
|
-
pass
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def load_entities_from_json(json_filepath) -> dict:
|
|
16
|
-
"""
|
|
17
|
-
Get entities from json
|
|
18
|
-
"""
|
|
19
|
-
try:
|
|
20
|
-
with open(json_filepath, 'r', encoding='utf-8') as file:
|
|
21
|
-
entities = json.loads(file.read())
|
|
22
|
-
except FileNotFoundError as error:
|
|
23
|
-
raise FileNotFoundError from error
|
|
24
|
-
|
|
25
|
-
return entities
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def load_entities_from_yaml(yaml_filepath):
|
|
29
|
-
"""
|
|
30
|
-
Get entities from yaml
|
|
31
|
-
"""
|
|
32
|
-
if 'yaml' not in sys.modules:
|
|
33
|
-
raise ModuleNotFoundError(
|
|
34
|
-
'PyYAML is not installed and is required to run this function. '
|
|
35
|
-
'To use this function, py -m pip install "sqlalchemyseed[yaml]"'
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
try:
|
|
39
|
-
with open(yaml_filepath, 'r', encoding='utf-8') as file:
|
|
40
|
-
entities = yaml.load(file.read(), Loader=yaml.SafeLoader)
|
|
41
|
-
except FileNotFoundError as error:
|
|
42
|
-
raise FileNotFoundError from error
|
|
43
|
-
|
|
44
|
-
return entities
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def load_entities_from_csv(csv_filepath: str, model) -> dict:
|
|
48
|
-
"""Load entities from csv file
|
|
49
|
-
|
|
50
|
-
:param csv_filepath: string csv file path
|
|
51
|
-
:param model: either str or class
|
|
52
|
-
:return: dict of entities
|
|
53
|
-
"""
|
|
54
|
-
with open(csv_filepath, 'r', encoding='utf-8') as file:
|
|
55
|
-
source_data = list(
|
|
56
|
-
map(dict, csv.DictReader(file, skipinitialspace=True)))
|
|
57
|
-
if isinstance(model, str):
|
|
58
|
-
model_name = model
|
|
59
|
-
else:
|
|
60
|
-
model_name = '.'.join([model.__module__, model.__name__])
|
|
61
|
-
|
|
62
|
-
entities = {'model': model_name, 'data': source_data}
|
|
63
|
-
|
|
64
|
-
return entities
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from sqlalchemyseed import loader
|
|
3
|
-
|
|
4
|
-
from src.sqlalchemyseed import load_entities_from_json
|
|
5
|
-
from src.sqlalchemyseed import load_entities_from_yaml
|
|
6
|
-
from src.sqlalchemyseed import load_entities_from_csv
|
|
7
|
-
|
|
8
|
-
class TestLoader(unittest.TestCase):
|
|
9
|
-
def test_load_entities_from_json(self):
|
|
10
|
-
entities = load_entities_from_json('tests/res/data.json')
|
|
11
|
-
self.assertEqual(len(entities), 6)
|
|
12
|
-
|
|
13
|
-
def test_load_entities_from_json_file_not_found(self):
|
|
14
|
-
self.assertRaises(FileNotFoundError,
|
|
15
|
-
lambda: load_entities_from_json('tests/res/non-existent-file'))
|
|
16
|
-
|
|
17
|
-
def test_load_entities_from_yaml(self):
|
|
18
|
-
entities = load_entities_from_yaml('tests/res/data.yml')
|
|
19
|
-
self.assertEqual(len(entities), 2)
|
|
20
|
-
|
|
21
|
-
def test_load_entities_from_yaml_file_not_found(self):
|
|
22
|
-
self.assertRaises(FileNotFoundError,
|
|
23
|
-
lambda: load_entities_from_yaml('tests/res/non-existent-file'))
|
|
24
|
-
|
|
25
|
-
def test_load_entities_from_csv_input_class(self):
|
|
26
|
-
from tests.models import Company
|
|
27
|
-
entities = load_entities_from_csv(
|
|
28
|
-
'tests/res/companies.csv', Company)
|
|
29
|
-
self.assertEqual(len(entities['data']), 3)
|
|
30
|
-
|
|
31
|
-
def test_load_entities_from_csv_input_model_string(self):
|
|
32
|
-
self.assertIsNotNone(load_entities_from_csv(
|
|
33
|
-
'tests/res/companies.csv', "tests.models.Company"))
|
|
34
|
-
|
|
35
|
-
def test_loader_yaml_not_installed(self):
|
|
36
|
-
loader.sys.modules.pop('yaml')
|
|
37
|
-
self.assertRaises(
|
|
38
|
-
ModuleNotFoundError,
|
|
39
|
-
lambda: load_entities_from_yaml('tests/res/data.yml')
|
|
40
|
-
)
|
|
41
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemyseed-2.2.0 → sqlalchemyseed-2.4.0}/src/sqlalchemyseed.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|