summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.rst5
-rw-r--r--doc/Makefile1
-rw-r--r--doc/conf.py1
-rw-r--r--doc/index.rst234
-rwxr-xr-xtox.ini2
-rwxr-xr-xyoyo/__init__.py2
-rw-r--r--yoyo/config.py14
-rw-r--r--yoyo/scripts/init.py55
-rwxr-xr-xyoyo/scripts/main.py28
-rwxr-xr-xyoyo/scripts/migrate.py24
-rw-r--r--yoyo/scripts/newmigration.py5
-rw-r--r--yoyo/tests/test_cli_script.py23
12 files changed, 322 insertions, 72 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 1b3c9b9..67d3983 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,8 @@
+8.1.0 (released 2022-11-03)
+---------------------------
+
+* Add a new ``yoyo init`` command
+
8.0.0 (released 2022-10-05)
---------------------------
diff --git a/doc/Makefile b/doc/Makefile
index 2070e97..c97ae1c 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -1,5 +1,6 @@
# Minimal makefile for Sphinx documentation
#
+export PATH:=$(PATH):../.tox/py310-sphinx/bin/
# You can set these variables from the command line.
SPHINXOPTS =
diff --git a/doc/conf.py b/doc/conf.py
index 2819070..ef9e4d8 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -48,6 +48,7 @@ extensions = [
"sphinx.ext.doctest",
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
+ "sphinxcontrib.programoutput",
]
# Add any paths that contain templates here, relative to this directory.
diff --git a/doc/index.rst b/doc/index.rst
index f3874a5..5937bc1 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -19,88 +19,184 @@ They can be as simple as this:
]
-Installation
-==================
+Installation and project setup
+==============================
-Install yoyo-migrations using from the PyPI, for example:
+Install yoyo-migrations from PyPI:
.. code:: shell
pip install yoyo-migrations
+Initialize yoyo for your project, supplying a database connection string and migrations directory name, for example:
-Command line usage
-==================
+.. code:: shell
+
+ yoyo init --database sqlite:///mydb.sqlite3 migrations
-Start a new migration:
+This will create a new, empty directory called ``migrations`` and install a
+``yoyo.ini`` configuration file in the current directory. The configuration file
+will contain any database credentials supplied on the command line. If you do
+not wish this to happen, then omit the ``--database`` argument from the
+command.
+
+Create a new migration by running ``yoyo new``. By default, a Python format file is generated, use ``--sql`` if you prefer SQL format:
.. code:: shell
- yoyo new ./migrations -m "Add column to foo"
+ yoyo new --sql
-Apply migrations from directory ``migrations`` to a PostgreSQL database:
+An editor will open with a template migration file.
+Add a comment explaining what the migration does followed by the SQL commands,
+for example:
-.. code:: shell
+.. code:: sql
- yoyo apply --database postgresql://scott:tiger@localhost/db ./migrations
+ -- Create table foo
+ -- depends:
-Rollback migrations previously applied to a MySQL database:
+ CREATE TABLE foo (
+ a int
+ );
+
+
+Save and exit, and the new migration file will be created.
+Check your migration has been created with ``yoyo list`` and apply it with
+``yoyo apply``:
.. code:: shell
- yoyo rollback --database mysql://scott:tiger@localhost/database ./migrations
+ $ yoyo list
+ $ yoyo apply
-Reapply (ie rollback then apply again) migrations to a SQLite database at
-location ``/home/sheila/important.db``:
-.. code:: shell
- yoyo reapply --database sqlite:////home/sheila/important.db ./migrations
+Command line usage
+==================
-List available migrations:
+You can see the list of available commands by running:
+
+.. command-output:: yoyo --help
-.. code:: shell
- yoyo list --database sqlite:////home/sheila/important.db ./migrations
+You can check options for any command with ``yoyo <command> --help``
+yoyo new
+--------
-During development, the ``yoyo develop`` command can be used to apply any
-unapplied migrations without further prompting:
+Start a new migration. ``yoyo new`` will create a new migration file and opens it your configured editor.
+
+By default a Python formation migration will be created. To use the simpler SQL format, specify ``--sql``.
.. code:: shell
- $ yoyo develop --database postgresql://localhost/mydb migrations
- Applying 3 migrations:
- [00000000_initial-schema]
- [00000001_add-table-foo]
- [00000002_add-table-bar]
+ yoyo new -m "Add column to foo"
+ yoyo new --sql
-If there are no migrations waiting to be applied the ``develop`` command will
-instead roll back and reapply the last migration:
+yoyo list
+----------
-.. code:: shell
+List available migrations. Each migration will be prefixed with one of ``U``
+(unapplied) or ``A`` (applied).
+
+yoyo apply
+----------
+
+Apply migrations to the target database. By default this will prompt you for each unapplied migration. To turn off prompting use ``--batch`` or specify ``batch_mode = on`` in ``yoyo.ini``.
+
+
+yoyo rollback
+-------------
+
+By default this will prompt you for each applied migration, starting with the most recently applied.
+
+If you wish to rollback a single migration, specify the migration with the ``-r``/``--revision`` flag. Note that this will also cause any migrations that depend on the selected migration to be rolled back.
+
+
+yoyo reapply
+-------------
+
+Reapply (ie rollback then apply again) migrations. As with `yoyo rollback`_, you can select a target migration with ``-r``/``--revision``
+
+
+yoyo develop
+------------
+
+Apply any unapplied migrations without prompting.
+
+If there are no unapplied migrations, rollback and reapply the most recent
+migration. Use ``yoyo develop -n <n>`` to act on just the *n* most recently
+applied migrations.
+
+yoyo mark
+---------
+
+Mark one or more migrations as applied, without actually applying them.
+
+yoyo unmark
+-----------
+
+Unmark one or more migrations as unapplied, without actually rolling them back.
- $ yoyo develop --database postgresql://localhost/dev ./migrations
- Reapplying 1 migration:
- [00000002_add-table-bar]
Connecting to a database
-------------------------
+========================
-Database connections are specified using a URL. Examples:
+Database connections are specified using a URL, for example:
-.. code:: ini
+.. code:: shell
+
+ yoyo list --database postgresql://scott:tiger@localhost/mydatabase
+
+The protocol part of the URL (the part before ``://``) is used to specify the backend.
+Yoyo provides the following core backends:
+
+- ``postgresql`` (psycopg2_)
+- ``postgresql+psycopg`` (psycopg3_)
+- ``mysql`` (pymysql_)
+- ``mysql+mysqldb`` (mysqlclient_)
+- ``sqlite`` (sqlite3_)
+
+And these backends have been contributed and are bundled with yoyo:
+
+- ``odbc`` (pyodbc_)
+- ``oracle`` (`cx_Oracle`_)
+- ``snowflake`` (snowflake_)
+- ``redshift`` (psycopg2_)
+
+How other parts of the URL are interpreted depends on the underlying backend
+and the DB-API driver used. The host part especially tends to be interpreted
+differently by drivers. A few of the more important differences are listed below.
+
+MySQL connections
+-----------------
+
+mysqlclient_ and pymysql_ have
+different ways to interpret the ``host`` part of the connection URL:
+
+- With mysqlclient_ (``mysql+mysqldb://``),
+ setting the host to ``localhost`` or leaving it empty causes the
+ driver to attempt a local unix socket connection.
+- In pymysql_ (``mysql://``),
+ the driver will attempt a tcp connection in both cases.
+ Specify a unix socket connection
+ with the ``unix_socket`` option (eg ``?unix_socket=/tmp/mysql.sock``)
+
+To enable SSL, specify ``?ssl=1`` and the following options as required:
+
+- ``sslca``
+- ``sslcapath``
+- ``sslcert``
+- ``sslkey``
+- ``sslcipher``
- # SQLite: use 4 slashes for an absolute database path on unix like platforms
- database = sqlite:////home/user/mydb.sqlite
+These options correspond to the ``ca``, ``capath``, ``cert``, ``key`` and ``cipher`` options used by `mysql_ssl_set <https://dev.mysql.com/doc/c-api/8.0/en/mysql-ssl-set.html>`_.
- # SQLite: use 3 slashes for a relative path
- database = sqlite:///mydb.sqlite
+Example configurations:
- # SQLite: absolute path on Windows.
- database = sqlite:///c:\home\user\mydb.sqlite
+.. code:: ini
# MySQL: Network database connection
database = mysql://scott:tiger@localhost/mydatabase
@@ -114,18 +210,51 @@ Database connections are specified using a URL. Examples:
# MySQL with SSL/TLS enabled
database = mysql+mysqldb://scott:tiger@localhost/mydatabase?ssl=yes&sslca=/path/to/cert
- # PostgreSQL: database connection
+PostgreSQL connections
+----------------------
+
+The psycopg family of drivers will use a unix socket if the host is left empty
+(or the value of ``PGHOST`` if this is set in your environment). Otherwise it will attempt a tcp connection to the specified host.
+
+To force a unix socket connection leave the host part of the URL
+empty and provide a ``host`` option that points to the directory containing the socket
+(eg ``postgresql:///mydb?host=/path/to/socket/``).
+
+The postgresql backends also allow a custom schema to be selected by specifying a ``schema`` option, eg ``postgresql://…/mydatabase?schema=myschema``.
+
+Example configurations:
+
+.. code:: ini
+
database = postgresql://scott:tiger@localhost/mydatabase
- # PostgreSQL: unix socket connection
+ # unix socket connection
database = postgresql://scott:tiger@/mydatabase
- # PostgreSQL with the newer psycopg 3 driver
+ # unix socket at a non-standard location and port number
+ database = postgresql://scott:tiger@/mydatabase?host=/var/run/postgresql&port=5434
+
+ # PostgreSQL with psycopg 3 driver
database = postgresql+psycopg://scott:tiger@localhost/mydatabase
- # PostgreSQL: changing the schema (via set search_path)
+ # Changing the default schema
database = postgresql://scott:tiger@/mydatabase?schema=some_schema
+SQLite connections
+------------------
+
+The SQLite backend ignores everything in the connection URL except the database
+name, which should be a filename, or the special value ``:memory:`` for an in-memory database.
+
+3 slashes are required to specify a relative path::
+
+ sqlite:///mydb.sqlite
+
+and 4 for an absolute path on unix-like platforms::
+
+ sqlite:////home/user/mydb.sqlite
+
+
Password security
-----------------
@@ -241,7 +370,7 @@ Migrations may declare dependencies on other migrations via the
]
-If you use the ``yoyo new`` command the ``_depends__`` attribute will be auto
+If you use the ``yoyo new`` command the ``__depends__`` attribute will be auto
populated for you.
@@ -451,7 +580,7 @@ rollbacks happen. For example:
Disabling transactions
----------------------
-You can disable transaction handling within a migration by setting
+Disable transaction handling within a migration by setting
``__transactional__ = False``, eg:
.. code:: python
@@ -536,8 +665,7 @@ in the package metadata (typically in ``setup.cfg``), for example:
mybackend = mypackage:MyBackend
-You can then use the backend by specifying ``'mybackend'`` as the driver
-protocol::
+Use the backend by specifying ``'mybackend'`` as the driver protocol::
.. code:: sh
@@ -611,3 +739,11 @@ Changelog
=========
.. include:: ../CHANGELOG.rst
+.. _mysqlclient: https://pypi.org/project/mysqlclient/
+.. _pymysql: https://pypi.org/project/pymysql/
+.. _psycopg2: https://pypi.org/project/psycopg2/
+.. _psycopg3: https://pypi.org/project/psycopg/
+.. _sqlite3: https://docs.python.org/3/library/sqlite3.html
+.. _pyodbc: https://pypi.org/project/pyodbc/
+.. _cx_Oracle: https://pypi.org/project/cx-Oracle/
+.. _snowflake: https://pypi.org/project/snowflake-connector-python/
diff --git a/tox.ini b/tox.ini
index 452f560..046d0d9 100755
--- a/tox.ini
+++ b/tox.ini
@@ -15,8 +15,8 @@ commands=pytest []
deps=
hg+http://hg.sr.ht/~olly/fresco-sphinx-theme#egg=fresco_sphinx_theme
sphinx
+ sphinxcontrib-programoutput
{[testenv]deps}
-
commands=
sphinx-build -M doctest "{toxinidir}/doc/" "{toxinidir}_build"
diff --git a/yoyo/__init__.py b/yoyo/__init__.py
index ef784dd..bf7c658 100755
--- a/yoyo/__init__.py
+++ b/yoyo/__init__.py
@@ -34,4 +34,4 @@ from yoyo.migrations import read_migrations
from yoyo.migrations import step
from yoyo.migrations import transaction
-__version__ = "8.0.1.dev0"
+__version__ = "8.1.1.dev0"
diff --git a/yoyo/config.py b/yoyo/config.py
index 9010cac..0569a12 100644
--- a/yoyo/config.py
+++ b/yoyo/config.py
@@ -84,8 +84,9 @@ def update_argparser_defaults(parser, defaults):
def read_config(src: Optional[str]) -> ConfigParser:
"""
- Read the configuration file at ``path``, or return an empty
- ConfigParse object if ``path`` is ``None``.
+ Read the configuration file at ``src`` and construct a ConfigParser instance.
+
+ If ``src`` is None a new, empty ConfigParse object will be created.
"""
if src is None:
return get_configparser(get_interpolation_defaults())
@@ -205,3 +206,12 @@ def find_config():
return path
d = os.path.dirname(d)
return None
+
+
+def config_changed(config: ConfigParser, path: str) -> bool:
+ def to_dict(config: ConfigParser):
+ return {k: dict(section.items()) for k, section in config.items()}
+
+ if Path(path).exists():
+ return to_dict(read_config(path)) != to_dict(config)
+ return True
diff --git a/yoyo/scripts/init.py b/yoyo/scripts/init.py
new file mode 100644
index 0000000..96b04e9
--- /dev/null
+++ b/yoyo/scripts/init.py
@@ -0,0 +1,55 @@
+# Copyright 2022 Oliver Cope
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pathlib
+import logging
+import sys
+
+from yoyo.config import CONFIG_FILENAME
+from yoyo.scripts.main import save_config
+
+logger = logging.getLogger("yoyo.migrations")
+
+
+def install_argparsers(global_parser, subparsers):
+ parser = subparsers.add_parser(
+ "init", parents=[global_parser], help="Initialize a new project"
+ )
+ parser.set_defaults(func=init)
+ parser.add_argument("sources", nargs=1, help="Directory for migration scripts")
+ parser.add_argument(
+ "-d",
+ "--database",
+ help="Database, eg 'sqlite:///path/to/sqlite.db' "
+ "or 'postgresql://user@host/db'",
+ )
+
+
+def init(args, config) -> int:
+
+ if args.config:
+ configpath = pathlib.Path(args.config)
+ else:
+ configpath = pathlib.Path.cwd() / CONFIG_FILENAME
+ migrations_path = pathlib.Path(args.sources[0]).resolve()
+
+ if configpath.exists():
+ print("Existing configuration file found, exiting", file=sys.stderr)
+ return 1
+ save_config(config, configpath)
+ print(f"Saved configuration to {configpath}")
+ if not migrations_path.exists():
+ migrations_path.mkdir(exist_ok=True, parents=True)
+ print(f"Created migrations directory {migrations_path}")
+ return 0
diff --git a/yoyo/scripts/main.py b/yoyo/scripts/main.py
index e6bae2b..3e9f76d 100755
--- a/yoyo/scripts/main.py
+++ b/yoyo/scripts/main.py
@@ -18,6 +18,7 @@ import configparser
import logging
import os
import sys
+import typing as t
from yoyo import connections
from yoyo import default_migration_table
@@ -27,6 +28,7 @@ from yoyo.config import CONFIG_FILENAME
from yoyo.config import find_config
from yoyo.config import read_config
from yoyo.config import save_config
+from yoyo.config import config_changed
from yoyo.config import update_argparser_defaults
verbosity_levels = {
@@ -46,11 +48,13 @@ class InvalidArgument(Exception):
pass
-def parse_args(argv=None):
+def parse_args(
+ argv=None,
+) -> t.Tuple[configparser.ConfigParser, argparse.ArgumentParser, argparse.Namespace]:
"""
Parse the config file and command line args.
- :return: tuple of (argparser, parsed_args)
+ :return: tuple of ``(parsed config file, argument parser, parsed arguments)``
"""
#: List of arguments whose defaults should be read from the config file
config_args = {
@@ -67,9 +71,8 @@ def parse_args(argv=None):
global_args, _ = globalparser.parse_known_args(argv)
# Read the config file and create a dictionary of defaults for argparser
- config = read_config(
- (global_args.config or find_config()) if global_args.use_config_file else None
- )
+ configfile = global_args.config or find_config()
+ config = read_config(configfile if global_args.use_config_file else None)
defaults = {}
for argname, getter in config_args.items():
@@ -137,8 +140,11 @@ def make_argparser():
subparsers = argparser.add_subparsers(help="Commands help")
- from . import migrate, newmigration
+ from . import migrate
+ from . import newmigration
+ from . import init
+ init.install_argparsers(global_parser, subparsers)
migrate.install_argparsers(global_parser, subparsers)
newmigration.install_argparsers(global_parser, subparsers)
@@ -301,13 +307,17 @@ def main(argv=None):
try:
if vars(args).get("func"):
- args.func(args, config)
+ exitcode = args.func(args, config)
except InvalidArgument as e:
argparser.error(e.args[0])
if config_is_empty and args.use_config_file and not args.batch_mode:
- prompt_save_config(config, args.config or CONFIG_FILENAME)
+ config_file = args.config or CONFIG_FILENAME
+ if config_changed(config, config_file):
+ prompt_save_config(config, config_file)
+
+ return exitcode
if __name__ == "__main__":
- main(sys.argv[1:])
+ sys.exit(main(sys.argv[1:]))
diff --git a/yoyo/scripts/migrate.py b/yoyo/scripts/migrate.py
index 15de7b0..060499a 100755
--- a/yoyo/scripts/migrate.py
+++ b/yoyo/scripts/migrate.py
@@ -256,30 +256,33 @@ def get_migrations(args, backend, direction=None):
return migrations
-def apply(args, config):
+def apply(args, config) -> int:
backend = get_backend(args, config)
with backend.lock():
migrations = get_migrations(args, backend)
backend.apply_migrations(migrations, args.force)
+ return 0
-def reapply(args, config):
+def reapply(args, config) -> int:
backend = get_backend(args, config)
with backend.lock():
migrations = get_migrations(args, backend)
backend.rollback_migrations(migrations, args.force)
migrations = backend.to_apply(migrations)
backend.apply_migrations(migrations, args.force)
+ return 0
-def rollback(args, config):
+def rollback(args, config) -> int:
backend = get_backend(args, config)
with backend.lock():
migrations = get_migrations(args, backend)
backend.rollback_migrations(migrations, args.force)
+ return 0
-def develop(args, config):
+def develop(args, config) -> int:
args.batch_mode = True
backend = get_backend(args, config)
with backend.lock():
@@ -303,28 +306,32 @@ def develop(args, config):
backend.rollback_migrations(migrations, args.force)
migrations = get_migrations(args, backend, "apply")
backend.apply_migrations(migrations, args.force)
+ return 0
-def mark(args, config):
+def mark(args, config) -> int:
backend = get_backend(args, config)
with backend.lock():
migrations = get_migrations(args, backend)
backend.mark_migrations(migrations)
+ return 0
-def unmark(args, config):
+def unmark(args, config) -> int:
backend = get_backend(args, config)
with backend.lock():
migrations = get_migrations(args, backend)
backend.unmark_migrations(migrations)
+ return 0
-def break_lock(args, config):
+def break_lock(args, config) -> int:
backend = get_backend(args, config)
backend.break_lock()
+ return 0
-def list_migrations(args, config):
+def list_migrations(args, config) -> int:
backend = get_backend(args, config)
migrations = read_migrations(*args.sources)
migrations = filter_migrations(migrations, args.match)
@@ -343,6 +350,7 @@ def list_migrations(args, config):
for m in migrations
)
print(tabulate.tabulate(data, headers=("STATUS", "ID", "SOURCE")))
+ return 0
def prompt_migrations(backend, migrations, direction):
diff --git a/yoyo/scripts/newmigration.py b/yoyo/scripts/newmigration.py
index 0629441..13b2986 100644
--- a/yoyo/scripts/newmigration.py
+++ b/yoyo/scripts/newmigration.py
@@ -89,7 +89,7 @@ def install_argparsers(global_parser, subparsers):
)
-def new_migration(args, config):
+def new_migration(args, config) -> int:
try:
directory = args.sources[0]
@@ -115,7 +115,7 @@ def new_migration(args, config):
else:
p = create_with_editor(config, directory, migration_source, extension)
if p is None:
- return
+ return 1
try:
command = config.get("DEFAULT", CONFIG_NEW_MIGRATION_COMMAND_KEY)
@@ -126,6 +126,7 @@ def new_migration(args, config):
pass
print("Created file", p)
+ return 0
def slugify(message):
diff --git a/yoyo/tests/test_cli_script.py b/yoyo/tests/test_cli_script.py
index 8199ee1..1160667 100644
--- a/yoyo/tests/test_cli_script.py
+++ b/yoyo/tests/test_cli_script.py
@@ -17,6 +17,7 @@ from datetime import datetime
from tempfile import mkdtemp
from functools import partial
from itertools import count
+import pathlib
import io
import os
import os.path
@@ -596,3 +597,25 @@ class TestDevelopCommand(TestInteractiveScript):
("m1", "apply"),
("m2", "apply"),
]
+
+
+class TestInitCommand(TestInteractiveScript):
+ def test_it_creates_project(self):
+ main(["-b", "init", "migrations", "--database", "sqlite://foo"])
+ config = (pathlib.Path(self.tmpdir) / "yoyo.ini").read_text()
+ assert "database = sqlite://foo" in config
+ assert "sources = migrations" in config
+ assert (pathlib.Path(self.tmpdir) / "migrations").is_dir()
+
+ def test_it_doesnt_overwrite(self):
+ configfile = pathlib.Path(self.tmpdir) / "yoyo.ini"
+ configfile.write_text("[existing file]")
+ main(["-b", "init", "migrations", "--database", "sqlite://foo"])
+ assert "database = sqlite://foo" not in configfile.read_text()
+ assert "existing file" in configfile.read_text()
+ assert not (pathlib.Path(self.tmpdir) / "migrations").exists()
+
+ def test_it_doesnt_prompt(self):
+ with patch("yoyo.scripts.main.prompt_save_config") as prompt_save_config:
+ main(["-b", "init", "migrations", "--database", "sqlite://foo"])
+ assert prompt_save_config.called is False