[
  {
    "path": ".gitignore",
    "content": ".env\ntusker.toml\ntusker.egg-info/\n__pycache__/\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Change Log\n\n## v0.5.1\n\n* Fix error message for invalid backends\n* Fix validation of unique backends\n* Fix error when `--(un)safe` or `--(no)privileges` were\n  not passed as arguments.\n* Replace `psycopg2-binary` dependency by `psycopg2`\n\n## v0.5.0\n\n* Added support for glob pattern lists for `schema.filename` and\n  `migration.filename`. Plain strings are still supported.\n* Add support for interpolated environment variables within config files.\n* Deprecate `migrations.directory` configuration option.\n* Update `tomlkit` to version `0.11`\n* Update locked dependency versions\n\n## v0.4.8\n\n* Fix \"`TypeError: dict is not a sequence\" error when\n  the schema or migration files contain percent characters (`%`).\n\n## v0.4.7\n\n* Fix \"A value is required for bind parameter ...\" error caused\n  by SQL files containing code looking like SQLAlchemy parameters\n  (`:<params>`).\n\n## v0.4.6 [YANKED]\n\n## v0.4.5\n\n* Add support for `\\*\\*` in glob pattern\n* Improve output of SQL errors\n\n## v0.4.4\n\n* Add default config for `migra` config section\n\n## v0.4.3\n\n* Fix `privileges` configuration option\n\n## v0.4.2\n\n* Add `migra.safe` and `migra.permission` to `tusker.toml`\n* Add `--safe` and `--unsafe` arguments\n* Add `--without-privileges` argument\n* Update `tomlkit` to version `0.10`\n* Update locked dependency versions\n\n## v0.4.1\n\n* Do not filter by `.sql` extension when using the `migrations.filename`\n  setting.\n\n## v0.4.0\n\n* Add `migrations.filename` setting which supports a `glob` pattern\n* Fix error messages for invalid configurations\n* Increase minimum `python` version to `3.6`\n* Update `migra` to version `3.0`\n* Update `tomlkit` to version `0.7`\n* Update `sqlalchemy` to version `1.4`\n* Update `psycopg2` to version `2.9`\n\n## v0.3.4\n\n* Fix quoting of database names\n\n## v0.3.3\n\n* Add support for mixing url with other database settings\n\n## v0.3.2\n\n* Fix transaction handling\n\n## v0.3.1\n\n* Execute files specified by `glob` pattern in sorted order\n\n## v0.3.0\n\n* Add `--version` argument\n* Add `glob` pattern support for `schema.filename` setting\n\n## v0.2.3\n\n* Replace f-Strings by .format() calls. This fixes Python 3.5 support.\n\n## v0.2.2\n\n* Add support for `database.schema` config option\n\n## v0.2.1\n\n* Add `--with-privileges` option to `diff` and `check` commands.\n\n## v0.2.0\n\n* Add `from` and `to` argument to `diff` command which makes it possible\n  to compare a schema file, migration files and an existing database.\n* Add `--reverse` option to `diff` command.\n* Add `check` command\n\n## v0.1.2\n\n* Fix closing of DB connections\n\n## v0.1.1\n\n* Escape schema and migration SQL before execution\n\n## v0.1.0\n\n* First release\n"
  },
  {
    "path": "LICENSE",
    "content": "This is free and unencumbered software released into the public domain.\n\nAnyone is free to copy, modify, publish, use, compile, sell, or\ndistribute this software, either in source code form or as a compiled\nbinary, for any purpose, commercial or non-commercial, and by any\nmeans.\n\nIn jurisdictions that recognize copyright laws, the author or authors\nof this software dedicate any and all copyright interest in the\nsoftware to the public domain. We make this dedication for the benefit\nof the public at large and to the detriment of our heirs and\nsuccessors. We intend this dedication to be an overt act of\nrelinquishment in perpetuity of all present and future rights to this\nsoftware under copyright law.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR\nOTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n\nFor more information, please refer to <http://unlicense.org>\n"
  },
  {
    "path": "README.md",
    "content": "# Tusker\n\n[![GitHub](https://img.shields.io/github/license/bikeshedder/tusker?label=License&logoColor=white&style=for-the-badge)](https://github.com/bikeshedder/tusker/blob/master/LICENSE)\n&nbsp;\n[![PyPI](https://img.shields.io/pypi/v/tusker?label=PyPI&logo=pypi&logoColor=white&style=for-the-badge)](https://pypi.org/project/tusker)\n\nA PostgreSQL specific migration tool\n\n## Elevator pitch\n\nDo you want to write your database schema directly as SQL\nwhich is understood by PostgreSQL?\n\nDo you want to be able to make changes to this schema and\ngenerate the SQL which is required to migrate between the\nold and new schema version?\n\nTusker does exactly this.\n\n## Installation\n\n```shell\npipx install tusker\n```\n\nNow you should be able to run tusker. Give it a try:\n\n```shell\ntusker --help\n```\n\n## Getting started\n\nOnce tusker is installed create a new file called `schema.sql`:\n\n```sql\nCREATE TABLE fruit (\n    id BIGINT GENERATED BY DEFAULT AS IDENTITY,\n    name TEXT NOT NULL UNIQUE\n);\n```\n\nYou probably want to create an empty `migrations` directory, too:\n\n```shell\nmkdir migrations\n```\n\nNow you should be able to create your first migration:\n\n```\ntusker diff\n```\n\nThe migration is printed to the console and all you need to do is\ncopy and paste the output into a new file in the migrations directory.\nAlternatively you can also pipe the output of `tusker diff` into the\ntarget file:\n\n```\ntusker diff > migrations/0001_initial.sql\n```\n\nAfter that check that your `schema.sql` and your `migrations` are in sync:\n\n```\ntusker diff\n```\n\nThis should give you an empty output. This means that there is no difference\nbetween applying the migrations in order and the target schema.\n\nAlternatively you can run the check command:\n\n```\ntusker check\n```\n\nIf you want to change the schema in the future simply change the `schema.sql`\nand run `tusker diff` to create the migration for you.\n\nGive it a try and change the `schema.sql`:\n\n```sql\nCREATE TABLE fruit (\n    id BIGINT GENERATED BY DEFAULT AS IDENTITY,\n    name TEXT NOT NULL UNIQUE,\n    color TEXT NOT NULL DEFAULT ''\n);\n```\n\nCreate a new migration:\n\n```\ntusker diff > migrations/0002_fruit_color.sql\n```\n\n**Congratulations! You are now using SQL to write your migrations. You are no longer limited by a 3rd party data definition language or an object relational wrapper.**\n\n## Configuration\n\nIn order to run tusker you do not need a configuration file. The following\ndefaults are assumed:\n\n- The file containing your database schema is called `schema.sql`\n- The directory containing the migrations is called `migrations`\n- Your current user can connect to the database using a unix\n  domain socket without a password.\n\nYou can also create a configuration file called `tusker.toml`. The default\nconfiguration looks like that:\n\n```toml\n[schema]\nfilename = \"schema.sql\"\n\n[migrations]\nfilename = \"migrations/*.sql\"\n\n[database]\n#host = \"\"\n#port = 5432\n#user = \"\"\n#password = \"\"\ndbname = \"my_awesome_db\"\n#schema = \"public\"\n\n[migra]\nsafe = false\nprivileges = false\n```\n\nInstead of the exploded form of `host`, `port`, etc. it\nis also possible to pass a connection URL:\n\n```toml\n[schema]\nfilename = \"schema.sql\"\n\n[migrations]\nfilename = \"migrations/*.sql\"\n\n[database]\nurl = \"postgresql:///my_awesome_db_connection\"\n```\n\nYou can also use an environment variable in place of a hard-coded value:\n\n```toml\n[database]\nurl = \"${DATABASE_URL}\"\n```\n\n## How can I use the generated SQL files?\n\nThe resulting SQL files can either be applied to the database by hand\nor by using one of the many great tools and libraries which support\napplying SQL files in order.\n\nSome recommendations are:\n\n- NodeJS: [marv](https://www.npmjs.com/package/marv)\n- Rust: [refinery](https://crates.io/crates/refinery)\n\n## How does it work?\n\nUpon startup `tusker` reads all files from the `migrations` directory\nand runs them on an empty database. Another empty database is created\nand the target schema is created. Then those two schemas are\ndiffed using the excellent [migra](https://pypi.org/project/migra/)\ntool and the output printed to the console.\n\n## Tusker is `unsafe` by default\n\nUnlike `migra` the `tusker` command by default does not throw an\nexception when a `drop`-statement is generated. Always check your\ngenerated migrations prior to running them. If you want the same\nbehavior as migra you can either use the `--safe` argument or set\nthe `migra.safe` configuration option to `True` in your `tusker.toml`\nfile.\n\n## FAQ\n\n### Is it possible to split the schema into multiple files?\n\nYes. This feature has been added in 0.3. You can now use `glob` patterns as\npart of the `schema.filename` setting. e.g.:\n\n```toml\n[schema]\nfilename = \"schema/*.sql\"\n```\n\nAs of 0.4.5 recursive glob patterns are supported as well:\n\n```toml\n[schema]\nfilename = \"schema/**/*.sql\"\n```\n\n### Is it possible to diff the schema and/or migrations against an existing database?\n\nYes. This feature has been added in 0.2. You can pass a `from` and `to`\nargument to the `tusker diff` command. Check the output of `tusker diff --help` for\nmore details.\n\n### How can I export initial schema from an existing database?\n\nFor exporting the initial schema you can use the native Postgres\n[pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) command with\nthe `--schema-only` option.\n\n### Tusker printed an error and left the temporary databases behind. How can I remove them?\n\nRun `tusker clean`. This will remove all databases which were created\nby previous runs of tusker. Tusker only removes databases which are\nmarked with a `CREATED BY TUSKER` comment.\n\n### What does the `dbname` setting in `tusker.toml` mean?\n\nThe `dbname` setting in `tusker.toml` specifies database name to be used when diffing\nagainst your database. This command will print out the difference between the current\ndatabase schema and the target schema:\n\n```shell\ntusker diff database\n```\n\nNote that this command is meant to be run after you have migrated your database.\n\nTusker also needs to create temporary databases when diffing against the `schema`\nand/or `migrations`. The two databases are called `{dbname}_{timestamp}_schema`\nand `{dbname}_{timestamp}_migrations`.\n\nThe `dbname` setting overrides the database name in connection `url` (if specified).\nIf neither a `dbname` nor a `url` is specified it will default to `tusker`. Calling\n`tusker diff database` only makes sense if you specify a `dbname` or your application\ndoes indeed use a database called `tusker`.\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.poetry]\nname = \"tusker\"\nversion = \"0.5.1\"\nauthors = [\"Michael P. Jung <michael.jung@terreon.de>\"]\nlicense = \"Unlicense\"\nreadme = \"README.md\"\ndescription = \"A PostgreSQL specific migration tool\"\nrepository = \"https://github.com/bikeshedder/tusker\"\nhomepage = \"https://github.com/bikeshedder/tusker\"\nclassifiers = [\n    \"Programming Language :: Python\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Development Status :: 4 - Beta\",\n    \"Topic :: Database\",\n    \"Topic :: Utilities\",\n]\n\n[tool.poetry.scripts]\ntusker = \"tusker:main\"\n\n[tool.poetry.dependencies]\npython = \"^3.7\"\nimportlib-metadata = {version = \"^1.0\", python = \"<3.8\"}\nmigra = \"^3.0.1621480950\"\ntomlkit = \"^0.11\"\nsqlalchemy = \"^1.4.25\"\npsycopg2 = \"^2.9.5\"\n\n[tool.poetry.dev-dependencies]\n\n[build-system]\nrequires = [\"poetry-core\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "tusker/__init__.py",
    "content": "import argparse\nfrom contextlib import contextmanager, ExitStack\nfrom glob import glob\nimport sys\nimport time\nimport warnings\n\nimport migra\nimport psycopg2\nfrom psycopg2 import sql\nimport sqlalchemy\n\nfrom .config import Config\n\nTUSKER_COMMENT = (\n    'CREATED BY TUSKER - If this table is left behind tusker probably '\n    'crashed and was not able to clean up after itself. Either try '\n    'running `tusker clean` or remove this database manually.'\n)\n\n\ntry:\n    import importlib.metadata as importlib_metadata\nexcept ModuleNotFoundError:\n    import importlib_metadata\ntry:\n    __version__ = importlib_metadata.version(__name__)\nexcept:\n    __version__ = 'unknown'\n\n\nclass ExecuteSqlError(Exception):\n    pass\n\n\ndef execute_sql_file(cursor, filename):\n    with open(filename) as fh:\n        sql = fh.read()\n    sql = sql.strip()\n    if not sql:\n        return\n    try:\n        cursor.exec_driver_sql(sql.replace('%', '%%'))\n    except sqlalchemy.exc.SQLAlchemyError as e:\n        # https://github.com/sqlalchemy/sqlalchemy/blob/9e7c068d669b209713da62da5748579f92d98129/lib/sqlalchemy/exc.py#L699-L709\n        # To provide more detail on the underlying error, but without printing the original SQL.\n        if e.orig:\n            orig = e.orig\n            error_text = \"(%s.%s) %s\" % (orig.__class__.__module__, orig.__class__.__name__, str(orig))\n        else:\n            error_text = str(e)\n        raise ExecuteSqlError('Error executing SQL file {}: {}'.format(filename, error_text))\n\n\nclass Tusker:\n\n    def __init__(self, config: Config, verbose=False):\n        self.config = config\n        self.verbose = verbose\n        self.conn = self._connect('template1')\n        self.conn.autocommit = True\n\n    def _connect(self, name):\n        args = self.config.database.args(dbname='template1')\n        return psycopg2.connect(**args)\n\n    def log(self, text):\n        if self.verbose:\n            print(text, file=sys.stderr)\n\n    @contextmanager\n    def createengine(self, dbname=None):\n        override = {'dbname': dbname} if dbname else {}\n        engine = sqlalchemy.create_engine(\n            'postgresql://',\n            connect_args=self.config.database.args(**override)\n        )\n        try:\n            yield engine\n        finally:\n            engine.dispose()\n\n    @contextmanager\n    def createdb(self, suffix):\n        cursor = self.conn.cursor()\n        now = int(time.time())\n        dbname = '{}_{}_{}'.format(\n            self.config.database.args()['dbname'],\n            now,\n            suffix\n        )\n        cursor.execute(sql.SQL('CREATE DATABASE {}').format(\n            sql.Identifier(dbname)\n        ))\n        cursor.execute(sql.SQL('COMMENT ON DATABASE {} IS {}').format(\n            sql.Identifier(dbname),\n            sql.Literal(TUSKER_COMMENT)\n        ))\n        try:\n            with self.createengine(dbname) as engine:\n                yield engine\n        finally:\n            cursor.execute(sql.SQL('DROP DATABASE {}').format(\n                sql.Identifier(dbname)\n            ))\n\n    @contextmanager\n    def mgr_schema(self):\n        with self.createdb('schema') as schema_engine:\n            with schema_engine.begin() as schema_cursor:\n                self.log('Creating original schema...')\n                for filename in self._get_schema_files():\n                    self.log('- {}'.format(filename))\n                    execute_sql_file(schema_cursor, filename)\n            yield schema_engine\n\n    @contextmanager\n    def mgr_migrations(self):\n        with self.createdb('migrations') as migrations_engine:\n            with migrations_engine.begin() as migrations_cursor:\n                self.log('Creating migrated schema...')\n                for filename in self._get_migration_files():\n                    self.log('- {}'.format(filename))\n                    execute_sql_file(migrations_cursor, filename)\n            yield migrations_engine\n\n    @contextmanager\n    def mgr_database(self):\n        with self.createengine() as database_engine:\n            with database_engine.begin() as database_cursor:\n                self.log('Observing database schema...')\n            yield database_engine\n\n    def mgr(self, name):\n        return getattr(self, 'mgr_{}'.format(name))()\n\n    def diff(self, source, target):\n        self.log('Creating databases...')\n        with self.mgr(source) as source, self.mgr(target) as target:\n            self.log('Diffing...')\n            migration = migra.Migration(\n                source,\n                target,\n                self.config.database.schema,\n            )\n            migration.set_safety(self.config.migra.safe)\n            migration.add_all_changes(privileges=self.config.migra.privileges)\n            return migration.sql\n\n    def check(self, backends):\n        with ExitStack() as stack:\n            managers = [(name, stack.enter_context(self.mgr(name)))\n                        for name in backends]\n            for i in range(len(managers)-1):\n                source, target = (managers[i], managers[i+1])\n                self.log('Diffing {} against {}...'.format(\n                    source[0],\n                    target[0]\n                ))\n                migration = migra.Migration(\n                    source[1],\n                    target[1],\n                    schema=self.config.database.schema\n                )\n                migration.set_safety(self.config.migra.safe)\n                migration.add_all_changes(privileges=self.config.migra.privileges)\n                if migration.sql:\n                    return (source[0], target[0])\n        return None\n\n    def clean(self):\n        cursor = self.conn.cursor()\n        try:\n            cursor.execute('''\n                SELECT db.datname\n                FROM pg_database db\n                JOIN pg_shdescription dsc ON dsc.objoid = db.oid\n                WHERE dsc.description = %s;\n            ''', (TUSKER_COMMENT,))\n            rows = cursor.fetchall()\n            for row in rows:\n                dbname = row[0]\n                self.log('Dropping {} ...'.format(dbname))\n                cursor.execute(sql.SQL('DROP DATABASE {}').format(\n                    sql.Identifier(dbname)\n                ))\n        finally:\n            cursor.close()\n\n    def _get_schema_files(self):\n        for pattern in self.config.schema.filename:\n            yield from sorted(glob(pattern, recursive=True))\n\n\n    def _get_migration_files(self):\n        for pattern in self.config.migrations.filename:\n            yield from sorted(glob(pattern, recursive=True))\n\ndef cmd_diff(args, cfg: Config):\n    tusker = Tusker(cfg, args.verbose)\n    source = args.source\n    target = args.target\n    if args.reverse:\n        source, target = target, source\n    try:\n        sql = tusker.diff(source, target)\n        print(sql, end='')\n    except ExecuteSqlError as e:\n        print(str(e), file=sys.stderr)\n        sys.exit(1)\n\n\ndef cmd_check(args, cfg: Config):\n    backends = args.backends\n    if 'all' in backends:\n        backends = ['migrations', 'schema', 'database']\n    tusker = Tusker(cfg, args.verbose)\n    try:\n        diff = tusker.check(backends)\n    except ExecuteSqlError as e:\n        print(str(e), file=sys.stderr)\n        sys.exit(1)\n    if diff:\n        print('Schemas differ: {} != {}'.format(diff[0], diff[1]))\n        print('Run `tusker diff` to see the differences')\n        sys.exit(1)\n    else:\n        print('Schemas are identical')\n        sys.exit(0)\n\n\ndef cmd_clean(args, cfg: Config):\n    tusker = Tusker(cfg, args.verbose)\n    tusker.clean()\n\n\nBACKEND_CHOICES = ['migrations', 'schema', 'database']\n\n\nclass ValidateBackends(argparse.Action):\n    def __call__(self, parser, args, values, option_string=None):\n        if 'all' in values:\n            values = BACKEND_CHOICES\n        else:\n            if len(values) <= 1:\n                choices = ', '.join(map(repr, BACKEND_CHOICES))\n                raise argparse.ArgumentError(\n                    self,\n                    (\n                        'at least two backends are required to perform '\n                        'the check (choose from {choices}) or pass \\'all\\' '\n                        'on its own.'.format(choices=choices)\n                    )\n                )\n            backends = set()\n            for value in values:\n                if value not in BACKEND_CHOICES:\n                    choices = ', '.join(map(repr, BACKEND_CHOICES + ['all']))\n                    msg = 'invalid choice: {!r} (choose from {})'.format(\n                        value,\n                        choices\n                    )\n                    raise argparse.ArgumentError(self, msg)\n                if value in backends:\n                    msg = 'duplicate found in backend list: {}'.format(value)\n                    raise argparse.ArgumentError(self, msg)\n                backends.add(value)\n        setattr(args, self.dest, values)\n\n\ndef add_migra_args(parser):\n    g = parser.add_mutually_exclusive_group()\n    g.add_argument(\n        '--safe',\n        help='throw an exception if drop-statements are generated.',\n        action='store_const',\n        dest='safe',\n        const=True,\n    )\n    g.add_argument(\n        '--unsafe',\n        help='don\\'t throw an exception if drop-statements are generated.',\n        action='store_const',\n        dest='safe',\n        const=False,\n    )\n    g = parser.add_mutually_exclusive_group()\n    g.add_argument(\n        '--with-privileges',\n        help='output privilege differences (ie. grant/revoke statements).',\n        action='store_const',\n        dest='privileges',\n        const=True,\n    )\n    g.add_argument(\n        '--without-privileges',\n        help='don\\'t output privilege differences.',\n        action='store_const',\n        dest='privileges',\n        const=False,\n    )\n\n\ndef main():\n    if not sys.warnoptions:\n        warnings.simplefilter(\"default\")\n    parser = argparse.ArgumentParser(\n        description='Generate a database migration.')\n    parser.add_argument(\n        '--version',\n        action='version',\n        version='%(prog)s {}'.format(__version__))\n    parser.add_argument(\n        '--verbose',\n        help='enable verbose output',\n        action='store_true',\n        default=False)\n    parser.add_argument(\n        '--config', '-c',\n        help='the configuration file. Default: tusker.toml',\n        default='tusker.toml')\n    subparsers = parser.add_subparsers(\n        dest='command',\n        required=True)\n    parser_diff = subparsers.add_parser(\n        'diff',\n        help='show differences between two schemas',\n        description='''\n            This command calculates the difference between two database schemas.\n            The from- and to-parameter accept one of the following backends:\n            migrations, schema, database\n        ''')\n    parser_diff.add_argument(\n        'source',\n        metavar='from',\n        nargs='?',\n        help='from-backend for the diff operation. Default: migrations',\n        choices=BACKEND_CHOICES,\n        default='migrations')\n    parser_diff.add_argument(\n        'target',\n        metavar='to',\n        nargs='?',\n        help='to-backend for the diff operation. Default: schema',\n        choices=BACKEND_CHOICES,\n        default='schema')\n    parser_diff.add_argument(\n        '--reverse', '-r',\n        help='swaps the \"from\" and \"to\" arguments creating a reverse diff',\n        action='store_true')\n    parser_diff.add_argument(\n        '--create-extensions-only',\n        help='Only output create extension statements, nothing else. ',\n        action='store_true',\n    )\n    add_migra_args(parser_diff)\n    parser_diff.set_defaults(func=cmd_diff)\n    parser_check = subparsers.add_parser(\n        'check',\n        help='check for differences between schemas',\n        description='''\n            This command checks for differences between two or more schemas.\n            Exit code 0 means that the schemas are all in sync. Otherwise the\n            exit code 1 is used. This is useful for continuous integration checks.\n        ''')\n    parser_check.set_defaults(func=cmd_check)\n    parser_check.add_argument(\n        'backends',\n        help=(\n            'at least two backends are required to diff against each other '\n            '(choose from {}). You can also pass \\'all\\' on its own to diff '\n            'all backends against each other.'\n        ).format(\n            ', '.join(map(repr, BACKEND_CHOICES))\n        ),\n        metavar='backend',\n        nargs='*',\n        default=['migrations', 'schema'],\n        action=ValidateBackends\n    )\n    add_migra_args(parser_check)\n    parser_clean = subparsers.add_parser(\n        'clean',\n        help='clean up left over *_migrations or *_schema tables')\n    parser_clean.set_defaults(func=cmd_clean)\n    args = parser.parse_args()\n    if hasattr(args, 'source') and hasattr(args, 'target') and args.source == args.target:\n        parser.error('to- and from-backend must not be identical')\n    cfg = Config(args.config)\n    if getattr(args, 'safe', None) is not None:\n        cfg.migra.safe = args.safe\n    if getattr(args, 'privileges', None) is not None:\n        cfg.migra.privileges = args.privileges\n    args.func(args, cfg)\n"
  },
  {
    "path": "tusker/config.py",
    "content": "import os\nimport re\n\nfrom psycopg2.extensions import parse_dsn\nfrom tomlkit.toml_file import TOMLFile\n\n\nclass Config:\n\n    def __init__(self, filename=None):\n        env = os.environ\n        filename = filename or 'tusker.toml'\n        toml = TOMLFile(filename)\n        try:\n            data = toml.read()\n        except FileNotFoundError:\n            data = {}\n        # time to validate some configuration variables\n        data.setdefault('database', {'dbname': 'tusker'})\n        data.setdefault('schema', {'filename': ['schema.sql']})\n        data.setdefault('migrations', {'filename': ['migrations/*.sql']})\n        data.setdefault('migra', {'safe': False, 'privileges': False})\n        self.schema = SchemaConfig(data['schema'])\n        self.migrations = MigrationsConfig(data['migrations'])\n        self.database = DatabaseConfig(data['database'])\n        self.migra = MigraConfig(data['migra'])\n\n    def __str__(self):\n        return 'Config(schema={}, migrations={}, database={}, migra={})'.format(\n            self.schema,\n            self.migrations,\n            self.database,\n            self.migra\n        )\n\n\ndef replace_from_env_var(matchobj):\n    env_variable = matchobj.group(1)\n    try:\n        return os.environ[env_variable]\n    except KeyError:\n        raise ConfigError.missing_env(env_variable)\n\n\nclass ConfigReader:\n\n    def __init__(self, data, path):\n        self.data = data\n        self.path = path\n\n    def get(self, name, type, required=False, default=None):\n        if name not in self.data:\n            if required:\n                raise ConfigError.missing('{}.{}'.format(self.path, name))\n            else:\n                return default\n        value = self.data[name]\n\n        if isinstance(value, str):\n            # Replace any environment variables\n            value = re.sub(r\"\\${([a-zA-Z_][a-zA-Z_0-9]*)}\", replace_from_env_var, value)\n\n        if not isinstance(value, type):\n            raise ConfigError.invalid(name, 'Not of type {}'.format(type))\n        return value\n\n    def get_list(self, name, required=False, default=None):\n        value = self.get(name, (str, list), required, default)\n        if isinstance(value, str):\n            value = [value]\n        else:\n            if value and not all(isinstance(x, str) for x in value):\n                raise ConfigError.invalid(name, 'Not a list of strings {}'.format(value))\n        return value\n\n\nclass SchemaConfig:\n\n    def __init__(self, data):\n        data = ConfigReader(data, 'schema')\n        self.filename = data.get_list('filename', default=['schema.sql'])\n\n    def __str__(self):\n        return 'SchemaConfig({!r})'.format(self.__dict__)\n\n\nclass MigrationsConfig:\n\n    def __init__(self, data):\n        data = ConfigReader(data, 'migrations')\n        directory = data.get('directory', str, False)\n        if directory:\n            import warnings\n            warnings.warn(\n                'The \"migrations.directory\" configuration option is '\n                'deprecated and support for this option will be removed '\n                'in the next version of tusker. Please replace this by '\n                'the \"migrations.filename\" option which does support '\n                'globbing patterns.',\n                DeprecationWarning,\n                stacklevel=2\n            )\n            filename = data.get_list('filename')\n            if filename:\n                raise ConfigError.invalid(\n                    'migrations directory and filename parameters '\n                    'are mutually exclusive',\n                )\n            else:\n                self.filename = ['{}/*.sql'.format(directory)]\n        else:\n            self.filename = data.get_list('filename', default=['migrations/*.sql'])\n\n    def __str__(self):\n        return 'MigrationsConfig({!r})'.format(self.__dict__)\n\n\nclass DatabaseConfig:\n\n    def __init__(self, data):\n        data = ConfigReader(data, 'database')\n        self.url = data.get('url', str)\n        self.host = data.get('host', str)\n        self.port = data.get('port', int)\n        self.dbname = data.get('dbname', str)\n        self.user = data.get('user', str)\n        self.password = data.get('password', str)\n        self.schema = data.get('schema', str)\n\n    def __str__(self):\n        return 'DatabaseConfig({!r})'.format(self.__dict__)\n\n    def args(self, **override):\n        if self.url:\n            args = parse_dsn(self.url)\n        else:\n            args = {}\n        for k in ['host', 'port', 'dbname', 'user', 'password']:\n            v = getattr(self, k)\n            if v is not None:\n                args[k] = v\n        if not args['dbname']:\n            args['dbname'] = 'tusker'\n        args.update(override)\n        return args\n\n\nclass MigraConfig:\n    def __init__(self, data):\n        data = ConfigReader(data, 'migra')\n        self.safe = data.get('safe', bool, default=False)\n        self.privileges = data.get('privileges', bool, default=False)\n\n\nclass ConfigError(RuntimeError):\n\n    @classmethod\n    def missing_env(cls, env_variable):\n        return cls('Missing environment variable: {}'.format(env_variable))\n\n    @classmethod\n    def missing(cls, name):\n        return cls('Missing configuration: {}'.format(name))\n\n    @classmethod\n    def invalid(cls, name, reason):\n        return cls('Invalid configuration: {}, {}'.format(name, reason))\n"
  },
  {
    "path": "tusker.toml.example",
    "content": "[schema]\nfilename = \"schema.sql\"\n\n[migrations]\nfilename = \"migrations/*.sql\"\n\n[database]\n#host = \"\"\n#port = 5432\n#user = \"\"\n#password = \"\"\ndbname = \"my_awesome_db\"\n#schema = \"public\"\n\n[migra]\nsafe = false\nprivileges = false\n"
  }
]