sqlalchemyseed 2.1.0__tar.gz → 2.2.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.
Files changed (31) hide show
  1. {sqlalchemyseed-2.1.0/src/sqlalchemyseed.egg-info → sqlalchemyseed-2.2.0}/PKG-INFO +35 -1
  2. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/README.md +34 -0
  3. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/pyproject.toml +3 -0
  4. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/__init__.py +1 -1
  5. sqlalchemyseed-2.2.0/src/sqlalchemyseed/__main__.py +6 -0
  6. sqlalchemyseed-2.2.0/src/sqlalchemyseed/cli.py +169 -0
  7. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0/src/sqlalchemyseed.egg-info}/PKG-INFO +35 -1
  8. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed.egg-info/SOURCES.txt +4 -0
  9. sqlalchemyseed-2.2.0/src/sqlalchemyseed.egg-info/entry_points.txt +2 -0
  10. sqlalchemyseed-2.2.0/tests/test_cli.py +133 -0
  11. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/LICENSE +0 -0
  12. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/setup.cfg +0 -0
  13. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/_future/__init__.py +0 -0
  14. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/_future/seeder.py +0 -0
  15. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/attribute.py +0 -0
  16. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/constants.py +0 -0
  17. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/dynamic_seeder.py +0 -0
  18. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/errors.py +0 -0
  19. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/json.py +0 -0
  20. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/loader.py +0 -0
  21. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/seeder.py +0 -0
  22. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/util.py +0 -0
  23. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed/validator.py +0 -0
  24. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed.egg-info/dependency_links.txt +0 -0
  25. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed.egg-info/requires.txt +0 -0
  26. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/src/sqlalchemyseed.egg-info/top_level.txt +0 -0
  27. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/tests/test_json.py +0 -0
  28. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/tests/test_loader.py +0 -0
  29. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/tests/test_seeder.py +0 -0
  30. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/tests/test_temp_seeder.py +0 -0
  31. {sqlalchemyseed-2.1.0 → sqlalchemyseed-2.2.0}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemyseed
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: SQLAlchemy Seeder
5
5
  Author-email: Jedy Matt Tabasco <hello@jedymatt.dev>
6
6
  License-Expression: MIT
@@ -89,6 +89,40 @@ data.json
89
89
  }
90
90
  ```
91
91
 
92
+ ## Command-line usage
93
+
94
+ Seed a database directly from data files without writing Python:
95
+
96
+ ```shell
97
+ sqlalchemyseed data.json --url sqlite:///app.db
98
+ ```
99
+
100
+ The command accepts one or more files and/or directories (a directory seeds
101
+ every `.json`/`.yaml`/`.yml` file inside it, in sorted order):
102
+
103
+ ```shell
104
+ sqlalchemyseed seeds/ --url "$DATABASE_URL"
105
+ sqlalchemyseed a.json b.yaml --url sqlite:///app.db
106
+ ```
107
+
108
+ The database URL may be passed with `--url` or the `DATABASE_URL` environment
109
+ variable. Model paths in the data files (e.g. `models.Person`) are resolved
110
+ against the current working directory, so run the command from your project
111
+ root.
112
+
113
+ Options:
114
+
115
+ - `--dry-run` — seed inside a transaction, then roll back (validate without writing)
116
+ - `--seeder hybrid` — use `HybridSeeder` instead of the default `Seeder`
117
+ - `--model models.Person` — required for CSV inputs, which are not self-describing
118
+ - `--ref-prefix` — override the relationship reference prefix (default `!`)
119
+
120
+ The same command is available as a module:
121
+
122
+ ```shell
123
+ python -m sqlalchemyseed data.json --url sqlite:///app.db
124
+ ```
125
+
92
126
  ## Documentation
93
127
 
94
128
  <https://sqlalchemyseed.readthedocs.io/>
@@ -64,6 +64,40 @@ data.json
64
64
  }
65
65
  ```
66
66
 
67
+ ## Command-line usage
68
+
69
+ Seed a database directly from data files without writing Python:
70
+
71
+ ```shell
72
+ sqlalchemyseed data.json --url sqlite:///app.db
73
+ ```
74
+
75
+ The command accepts one or more files and/or directories (a directory seeds
76
+ every `.json`/`.yaml`/`.yml` file inside it, in sorted order):
77
+
78
+ ```shell
79
+ sqlalchemyseed seeds/ --url "$DATABASE_URL"
80
+ sqlalchemyseed a.json b.yaml --url sqlite:///app.db
81
+ ```
82
+
83
+ The database URL may be passed with `--url` or the `DATABASE_URL` environment
84
+ variable. Model paths in the data files (e.g. `models.Person`) are resolved
85
+ against the current working directory, so run the command from your project
86
+ root.
87
+
88
+ Options:
89
+
90
+ - `--dry-run` — seed inside a transaction, then roll back (validate without writing)
91
+ - `--seeder hybrid` — use `HybridSeeder` instead of the default `Seeder`
92
+ - `--model models.Person` — required for CSV inputs, which are not self-describing
93
+ - `--ref-prefix` — override the relationship reference prefix (default `!`)
94
+
95
+ The same command is available as a module:
96
+
97
+ ```shell
98
+ python -m sqlalchemyseed data.json --url sqlite:///app.db
99
+ ```
100
+
67
101
  ## Documentation
68
102
 
69
103
  <https://sqlalchemyseed.readthedocs.io/>
@@ -27,6 +27,9 @@ yaml = [
27
27
  "PyYAML>=6.0",
28
28
  ]
29
29
 
30
+ [project.scripts]
31
+ sqlalchemyseed = "sqlalchemyseed.cli:main"
32
+
30
33
  [project.urls]
31
34
  Homepage = "https://github.com/jedymatt/sqlalchemyseed"
32
35
  Documentation = "https://sqlalchemyseed.readthedocs.io/"
@@ -11,7 +11,7 @@ from . import util
11
11
  from . import attribute
12
12
 
13
13
 
14
- __version__ = "2.1.0"
14
+ __version__ = "2.2.0"
15
15
 
16
16
  if __name__ == '__main__':
17
17
  pass
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m sqlalchemyseed``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,169 @@
1
+ """Command-line interface for seeding a database from data files."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import sqlalchemy
9
+ from sqlalchemy.orm import Session
10
+
11
+ from . import loader
12
+ from .seeder import HybridSeeder, Seeder
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
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ """Build the argument parser for the ``sqlalchemyseed`` command."""
24
+ parser = argparse.ArgumentParser(
25
+ prog="sqlalchemyseed",
26
+ description="Seed a database from JSON, YAML, or CSV data files.",
27
+ )
28
+ parser.add_argument(
29
+ "paths",
30
+ nargs="+",
31
+ metavar="PATH",
32
+ help="data files or directories to seed from",
33
+ )
34
+ parser.add_argument(
35
+ "--url",
36
+ help="SQLAlchemy database URL (defaults to the DATABASE_URL env var)",
37
+ )
38
+ parser.add_argument(
39
+ "--seeder",
40
+ choices=("basic", "hybrid"),
41
+ default="basic",
42
+ help="seeder to use (default: basic)",
43
+ )
44
+ parser.add_argument(
45
+ "--model",
46
+ help="model class path (e.g. models.Person) required for CSV inputs",
47
+ )
48
+ parser.add_argument(
49
+ "--ref-prefix",
50
+ default="!",
51
+ help="prefix marking relationship references (default: !)",
52
+ )
53
+ parser.add_argument(
54
+ "--dry-run",
55
+ action="store_true",
56
+ help="seed within a transaction but roll back instead of committing",
57
+ )
58
+ return parser
59
+
60
+
61
+ def collect_files(paths) -> list:
62
+ """Expand each path into data files, walking directories in sorted order."""
63
+ files = []
64
+ for raw_path in paths:
65
+ files.extend(_files_in(Path(raw_path)))
66
+ return files
67
+
68
+
69
+ def _files_in(path: Path) -> list:
70
+ """Return the data files contributed by a single path argument."""
71
+ if path.is_dir():
72
+ return _discover_directory(path)
73
+ if path.is_file():
74
+ return [path]
75
+ raise FileNotFoundError(f"path does not exist: {path}")
76
+
77
+
78
+ def _discover_directory(directory: Path) -> list:
79
+ """Return the JSON/YAML files inside a directory, sorted by name."""
80
+ discovered = sorted(
81
+ child for child in directory.iterdir()
82
+ if child.suffix.lower() in _DISCOVERABLE_EXTENSIONS
83
+ )
84
+ if not discovered:
85
+ raise FileNotFoundError(
86
+ f"no JSON or YAML seed files found in directory: {directory}"
87
+ )
88
+ return discovered
89
+
90
+
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
+ def _make_seeder(name, session, ref_prefix):
111
+ """Return the seeder implementation selected on the command line."""
112
+ if name == "hybrid":
113
+ return HybridSeeder(session, ref_prefix=ref_prefix)
114
+ return Seeder(session, ref_prefix=ref_prefix)
115
+
116
+
117
+ def _seed_all(seeder, files, model) -> int:
118
+ """Seed every file through the seeder and return the entity count."""
119
+ seeded = 0
120
+ for path in files:
121
+ seeder.seed(load_file(path, model))
122
+ seeded += len(seeder.instances)
123
+ return seeded
124
+
125
+
126
+ def main(argv=None) -> int:
127
+ """Entry point for the ``sqlalchemyseed`` command."""
128
+ parser = build_parser()
129
+ args = parser.parse_args(argv)
130
+
131
+ url = args.url or os.environ.get("DATABASE_URL")
132
+ if not url:
133
+ parser.error("a database URL is required via --url or the DATABASE_URL env var")
134
+
135
+ # Make the caller's project importable so model paths like "models.Person"
136
+ # resolve against the current working directory.
137
+ sys.path.insert(0, os.getcwd())
138
+
139
+ try:
140
+ files = collect_files(args.paths)
141
+ except FileNotFoundError as error:
142
+ parser.error(str(error))
143
+
144
+ engine = sqlalchemy.create_engine(url)
145
+ with Session(engine) as session:
146
+ seeder = _make_seeder(args.seeder, session, args.ref_prefix)
147
+ try:
148
+ seeded = _seed_all(seeder, files, args.model)
149
+ except Exception as error: # noqa: BLE001 - report any seeding failure as a clean exit
150
+ session.rollback()
151
+ print(f"error: {error}", file=sys.stderr)
152
+ return 1
153
+
154
+ return _finish(session, seeded, len(files), args.dry_run)
155
+
156
+
157
+ def _finish(session, seeded, file_count, dry_run) -> int:
158
+ """Commit or roll back the seeded session and print a summary."""
159
+ if dry_run:
160
+ session.rollback()
161
+ print(f"Dry run: would seed {seeded} entities from {file_count} file(s) (rolled back).")
162
+ return 0
163
+ session.commit()
164
+ print(f"Seeded {seeded} entities from {file_count} file(s).")
165
+ return 0
166
+
167
+
168
+ if __name__ == "__main__": # pragma: no cover
169
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemyseed
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: SQLAlchemy Seeder
5
5
  Author-email: Jedy Matt Tabasco <hello@jedymatt.dev>
6
6
  License-Expression: MIT
@@ -89,6 +89,40 @@ data.json
89
89
  }
90
90
  ```
91
91
 
92
+ ## Command-line usage
93
+
94
+ Seed a database directly from data files without writing Python:
95
+
96
+ ```shell
97
+ sqlalchemyseed data.json --url sqlite:///app.db
98
+ ```
99
+
100
+ The command accepts one or more files and/or directories (a directory seeds
101
+ every `.json`/`.yaml`/`.yml` file inside it, in sorted order):
102
+
103
+ ```shell
104
+ sqlalchemyseed seeds/ --url "$DATABASE_URL"
105
+ sqlalchemyseed a.json b.yaml --url sqlite:///app.db
106
+ ```
107
+
108
+ The database URL may be passed with `--url` or the `DATABASE_URL` environment
109
+ variable. Model paths in the data files (e.g. `models.Person`) are resolved
110
+ against the current working directory, so run the command from your project
111
+ root.
112
+
113
+ Options:
114
+
115
+ - `--dry-run` — seed inside a transaction, then roll back (validate without writing)
116
+ - `--seeder hybrid` — use `HybridSeeder` instead of the default `Seeder`
117
+ - `--model models.Person` — required for CSV inputs, which are not self-describing
118
+ - `--ref-prefix` — override the relationship reference prefix (default `!`)
119
+
120
+ The same command is available as a module:
121
+
122
+ ```shell
123
+ python -m sqlalchemyseed data.json --url sqlite:///app.db
124
+ ```
125
+
92
126
  ## Documentation
93
127
 
94
128
  <https://sqlalchemyseed.readthedocs.io/>
@@ -2,7 +2,9 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/sqlalchemyseed/__init__.py
5
+ src/sqlalchemyseed/__main__.py
5
6
  src/sqlalchemyseed/attribute.py
7
+ src/sqlalchemyseed/cli.py
6
8
  src/sqlalchemyseed/constants.py
7
9
  src/sqlalchemyseed/dynamic_seeder.py
8
10
  src/sqlalchemyseed/errors.py
@@ -14,10 +16,12 @@ src/sqlalchemyseed/validator.py
14
16
  src/sqlalchemyseed.egg-info/PKG-INFO
15
17
  src/sqlalchemyseed.egg-info/SOURCES.txt
16
18
  src/sqlalchemyseed.egg-info/dependency_links.txt
19
+ src/sqlalchemyseed.egg-info/entry_points.txt
17
20
  src/sqlalchemyseed.egg-info/requires.txt
18
21
  src/sqlalchemyseed.egg-info/top_level.txt
19
22
  src/sqlalchemyseed/_future/__init__.py
20
23
  src/sqlalchemyseed/_future/seeder.py
24
+ tests/test_cli.py
21
25
  tests/test_json.py
22
26
  tests/test_loader.py
23
27
  tests/test_seeder.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sqlalchemyseed = sqlalchemyseed.cli:main
@@ -0,0 +1,133 @@
1
+ """Tests for the sqlalchemyseed command-line interface."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from sqlalchemy import create_engine
7
+ from sqlalchemy.orm import Session
8
+
9
+ from sqlalchemyseed import cli
10
+ from tests.models import Base, Person
11
+
12
+
13
+ @pytest.fixture
14
+ def db_url(tmp_path):
15
+ """A file-backed SQLite URL with the test schema already created."""
16
+ url = f"sqlite:///{tmp_path / 'seed.db'}"
17
+ engine = create_engine(url)
18
+ Base.metadata.create_all(engine)
19
+ engine.dispose()
20
+ return url
21
+
22
+
23
+ def count_persons(url):
24
+ with Session(create_engine(url)) as session:
25
+ return session.query(Person).count()
26
+
27
+
28
+ def write_json(path, entities):
29
+ path.write_text(json.dumps(entities), encoding="utf-8")
30
+ return path
31
+
32
+
33
+ def person_entities(*names):
34
+ return {"model": "tests.models.Person", "data": [{"name": name} for name in names]}
35
+
36
+
37
+ def test_seed_json_file(tmp_path, db_url):
38
+ data_file = write_json(tmp_path / "people.json", person_entities("Alice", "Bob"))
39
+
40
+ assert cli.main([str(data_file), "--url", db_url]) == 0
41
+ assert count_persons(db_url) == 2
42
+
43
+
44
+ def test_seed_yaml_file(tmp_path, db_url):
45
+ yaml_file = tmp_path / "people.yaml"
46
+ yaml_file.write_text(
47
+ "model: tests.models.Person\ndata:\n - name: Carol\n", encoding="utf-8"
48
+ )
49
+
50
+ assert cli.main([str(yaml_file), "--url", db_url]) == 0
51
+ assert count_persons(db_url) == 1
52
+
53
+
54
+ def test_seed_csv_file_requires_model(tmp_path, db_url):
55
+ csv_file = tmp_path / "people.csv"
56
+ csv_file.write_text("name\nDave\nErin\n", encoding="utf-8")
57
+
58
+ exit_code = cli.main(
59
+ [str(csv_file), "--url", db_url, "--model", "tests.models.Person"]
60
+ )
61
+
62
+ assert exit_code == 0
63
+ assert count_persons(db_url) == 2
64
+
65
+
66
+ def test_csv_without_model_fails(tmp_path, db_url, capsys):
67
+ csv_file = tmp_path / "people.csv"
68
+ csv_file.write_text("name\nDave\n", encoding="utf-8")
69
+
70
+ assert cli.main([str(csv_file), "--url", db_url]) == 1
71
+ assert "requires --model" in capsys.readouterr().err
72
+ assert count_persons(db_url) == 0
73
+
74
+
75
+ def test_seed_directory(tmp_path, db_url):
76
+ seeds = tmp_path / "seeds"
77
+ seeds.mkdir()
78
+ write_json(seeds / "01_first.json", person_entities("Alice"))
79
+ write_json(seeds / "02_second.json", person_entities("Bob", "Carol"))
80
+
81
+ assert cli.main([str(seeds), "--url", db_url]) == 0
82
+ assert count_persons(db_url) == 3
83
+
84
+
85
+ def test_multiple_paths(tmp_path, db_url):
86
+ first = write_json(tmp_path / "a.json", person_entities("Alice"))
87
+ second = write_json(tmp_path / "b.json", person_entities("Bob"))
88
+
89
+ assert cli.main([str(first), str(second), "--url", db_url]) == 0
90
+ assert count_persons(db_url) == 2
91
+
92
+
93
+ def test_dry_run_rolls_back(tmp_path, db_url, capsys):
94
+ data_file = write_json(tmp_path / "people.json", person_entities("Alice", "Bob"))
95
+
96
+ assert cli.main([str(data_file), "--url", db_url, "--dry-run"]) == 0
97
+ assert "Dry run" in capsys.readouterr().out
98
+ assert count_persons(db_url) == 0
99
+
100
+
101
+ def test_hybrid_seeder(tmp_path, db_url):
102
+ data_file = write_json(tmp_path / "people.json", person_entities("Alice"))
103
+
104
+ assert cli.main([str(data_file), "--url", db_url, "--seeder", "hybrid"]) == 0
105
+ assert count_persons(db_url) == 1
106
+
107
+
108
+ def test_url_from_environment(tmp_path, db_url, monkeypatch):
109
+ monkeypatch.setenv("DATABASE_URL", db_url)
110
+ data_file = write_json(tmp_path / "people.json", person_entities("Alice"))
111
+
112
+ assert cli.main([str(data_file)]) == 0
113
+ assert count_persons(db_url) == 1
114
+
115
+
116
+ def test_missing_url_errors(tmp_path, monkeypatch):
117
+ monkeypatch.delenv("DATABASE_URL", raising=False)
118
+ data_file = write_json(tmp_path / "people.json", person_entities("Alice"))
119
+
120
+ with pytest.raises(SystemExit):
121
+ cli.main([str(data_file)])
122
+
123
+
124
+ def test_nonexistent_path_errors(db_url):
125
+ with pytest.raises(SystemExit):
126
+ cli.main(["does_not_exist.json", "--url", db_url])
127
+
128
+
129
+ def test_unsupported_file_type(tmp_path, db_url):
130
+ bad_file = tmp_path / "people.txt"
131
+ bad_file.write_text("nope", encoding="utf-8")
132
+
133
+ assert cli.main([str(bad_file), "--url", db_url]) == 1
File without changes
File without changes