add migrate and cli

This commit is contained in:
long2ice 2020-05-13 18:59:24 +08:00
parent 4e7e1626aa
commit a5a5de529b
16 changed files with 338 additions and 43 deletions

3
MANIFEST.in Normal file
View File

@ -0,0 +1,3 @@
include LICENSE
include README.rst
include requirements.txt

View File

@ -1,11 +1 @@
__version__ = '0.1.0'
from alice.cmd import CommandLine
def main(argv):
command = CommandLine(argv)
if __name__ == '__main__':
main(None)

View File

@ -63,7 +63,7 @@ class DDL:
)
if field_object.description else "",
is_primary_key=field_object.pk,
default=default,
default=default
)
)

83
alice/cli.py Normal file
View File

@ -0,0 +1,83 @@
import importlib
import os
import sys
from enum import Enum
import click
from click import BadParameter, ClickException
sys.path.append(os.getcwd())
class Color(str, Enum):
green = 'green'
red = 'red'
@click.group(context_settings={'help_option_names': ['-h', '--help']})
@click.option('-c', '--config', default='settings',
help='Tortoise-ORM config module, will read config variable from it, default is `settings`.')
@click.option('-t', '--tortoise-orm', default='TORTOISE_ORM',
help='Tortoise-ORM config dict variable, default is `TORTOISE_ORM`.')
@click.option('-l', '--location', default='./migrations',
help='Migrate store location, default is `./migrations`.')
@click.option('--connection', default='default', help='Tortoise-ORM connection name, default is `default`.')
@click.pass_context
def cli(ctx, config, tortoise_orm, location, connection):
ctx.ensure_object(dict)
try:
config_module = importlib.import_module(config)
config = getattr(config_module, tortoise_orm, None)
if not config:
raise BadParameter(param_hint=['--config'],
message=f'Can\'t get "{tortoise_orm}" from module "{config_module}"')
ctx.obj['config'] = config
ctx.obj['location'] = location
if connection not in config.get('connections').keys():
raise BadParameter(param_hint=['--connection'], message=f'No connection found in "{config}"')
except ModuleNotFoundError:
raise BadParameter(param_hint=['--tortoise-orm'], message=f'No module named "{config}"')
@cli.command()
@click.pass_context
def migrate(ctx):
config = ctx.obj['config']
@cli.command()
@click.pass_context
def upgrade():
pass
@cli.command()
@click.pass_context
def downgrade():
pass
@cli.command()
@click.pass_context
def initdb():
pass
@cli.command()
@click.option('--overwrite', type=bool, default=False, help='Overwrite old_models.py.')
@click.pass_context
def init(ctx, overwrite):
location = ctx.obj['location']
config = ctx.obj['config']
if not os.path.isdir(location) or overwrite:
os.mkdir(location)
connections = config.get('connections').keys()
for connection in connections:
dirname = os.path.join(location, connection)
if not os.path.isdir(dirname):
os.mkdir(dirname)
click.secho(f'Success create migrate location {dirname}', fg=Color.green)
if overwrite:
pass
else:
raise ClickException('Already inited')

View File

@ -1,18 +0,0 @@
class CommandLine:
def __init__(self, argv):
self.argv = argv
def migrate(self):
pass
def upgrade(self):
pass
def downgrade(self):
pass
def init_db(self):
pass
def init(self):
pass

View File

88
alice/migrate.py Normal file
View File

@ -0,0 +1,88 @@
import importlib
import inspect
from typing import List, Type, Dict
from tortoise import Model, ForeignKeyFieldInstance
from tortoise.fields import Field
from alice.backends import DDL
class Migrate:
operators: List
ddl: DDL
def __init__(self, ddl: DDL):
self.operators = []
self.ddl = ddl
def diff_models_module(self, old_models_module, new_models_module):
old_module = importlib.import_module(old_models_module)
old_models = {}
new_models = {}
for name, obj in inspect.getmembers(old_module):
if inspect.isclass(obj) and issubclass(obj, Model):
old_models[obj.__name__] = obj
new_module = importlib.import_module(new_models_module)
for name, obj in inspect.getmembers(new_module):
if inspect.isclass(obj) and issubclass(obj, Model):
new_models[obj.__name__] = obj
self.diff_models(old_models, new_models)
def diff_models(self, old_models: Dict[str, Type[Model]], new_models: Dict[str, Type[Model]]):
for new_model_str, new_model in new_models.items():
if new_model_str not in old_models.keys():
self.add_model(new_model)
else:
self.diff_model(old_models.get(new_model_str), new_model)
for old_model in old_models:
if old_model not in new_models.keys():
self.remove_model(old_models.get(old_model))
def _add_operator(self, operator):
self.operators.append(operator)
def add_model(self, model: Type[Model]):
self._add_operator(self.ddl.create_table(model))
def remove_model(self, model: Type[Model]):
self._add_operator(self.ddl.drop_table(model))
def diff_model(self, old_model: Type[Model], new_model: Type[Model]):
old_fields_map = old_model._meta.fields_map
new_fields_map = new_model._meta.fields_map
old_keys = old_fields_map.keys()
new_keys = new_fields_map.keys()
for new_key in new_keys:
new_field = new_fields_map.get(new_key)
if new_key not in old_keys:
self._add_field(new_model, new_field)
else:
old_field = old_fields_map.get(new_key)
if old_field.index and not new_field.index:
self._remove_index(old_model, old_field)
elif new_field.index and not old_field.index:
self._add_index(new_model, new_field)
for old_key in old_keys:
if old_key not in new_keys:
field = old_fields_map.get(old_key)
self._remove_field(old_model, field)
def _remove_index(self, model: Type[Model], field: Field):
self._add_operator(self.ddl.drop_index(model, [field.model_field_name], field.unique))
def _add_index(self, model: Type[Model], field: Field):
self._add_operator(self.ddl.add_index(model, [field.model_field_name], field.unique))
def _add_field(self, model: Type[Model], field: Field):
if isinstance(field, ForeignKeyFieldInstance):
self._add_operator(self.ddl.add_fk(model, field))
else:
self._add_operator(self.ddl.add_column(model, field))
def _remove_field(self, model: Type[Model], field: Field):
if isinstance(field, ForeignKeyFieldInstance):
self._add_operator(self.ddl.drop_fk(model, field))
self._add_operator(self.ddl.drop_column(model, field.model_field_name))

17
alice/utils.py Normal file
View File

@ -0,0 +1,17 @@
import re
def cp_models(old_model_file, new_model_file, new_app):
"""
cp models file to old_models.py and rename model app
:param old_app:
:param new_app:
:param old_model_file:
:param new_model_file:
:return:r
"""
pattern = r'(ManyToManyField|ForeignKeyField|OneToOneField)\((model_name)?(\"|\')(?P<app>\w+).+\)'
with open(old_model_file, 'r') as f:
content = f.read()
ret = re.sub(pattern, rf'{new_app} \g<app>', content)
print(ret)

24
poetry.lock generated
View File

@ -48,6 +48,14 @@ optional = false
python-versions = "*"
version = "2.1.3"
[[package]]
category = "main"
description = "Composable command line interface toolkit"
name = "click"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "7.1.2"
[[package]]
category = "main"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
@ -173,7 +181,7 @@ description = "Easy async ORM for python, built with relations in mind"
name = "tortoise-orm"
optional = false
python-versions = "*"
version = "0.16.10"
version = "0.16.11"
[package.dependencies]
aiosqlite = ">=0.11.0"
@ -182,6 +190,10 @@ iso8601 = ">=0.1.12"
pypika = ">=0.36.5"
typing-extensions = ">=3.7"
[package.source]
reference = "72f84f0848dc68041157f03e60cd1c92b0ee5137"
type = "git"
url = "https://github.com/tortoise/tortoise-orm.git"
[[package]]
category = "main"
description = "Backported and Experimental Type Hints for Python 3.5+"
@ -191,7 +203,7 @@ python-versions = "*"
version = "3.7.4.2"
[metadata]
content-hash = "0b4c3c6eb6ed2e84b03745542ebc42dc53e6e5466bea0c4eee991bcd063a6ef3"
content-hash = "708876857d4653fd45cb251e9a1689c4158966f42da2efca1e8167becef89837"
python-versions = "^3.8"
[metadata.files]
@ -240,6 +252,10 @@ cffi = [
ciso8601 = [
{file = "ciso8601-2.1.3.tar.gz", hash = "sha256:bdbb5b366058b1c87735603b23060962c439ac9be66f1ae91e8c7dbd7d59e262"},
]
click = [
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
]
cryptography = [
{file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"},
{file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"},
@ -306,9 +322,7 @@ toml = [
{file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"},
{file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"},
]
tortoise-orm = [
{file = "tortoise-orm-0.16.10.tar.gz", hash = "sha256:b3f4fdc9edabfc88413b7c5297b6cb9408420d1a97d9ad25051170b2b6228e02"},
]
tortoise-orm = []
typing-extensions = [
{file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"},
{file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"},

View File

@ -6,8 +6,9 @@ authors = ["long2ice <long2ice@gmail.com>"]
[tool.poetry.dependencies]
python = "^3.8"
tortoise-orm = "*"
tortoise-orm = {git = "https://github.com/tortoise/tortoise-orm.git", branch = "develop"}
aiomysql = "*"
click = "*"
[tool.poetry.dev-dependencies]
taskipy = "*"

View File

@ -2,6 +2,7 @@ aiomysql==0.0.20
aiosqlite==0.13.0
cffi==1.14.0
ciso8601==2.1.3; sys_platform != "win32" and implementation_name == "cpython"
click==7.1.2
cryptography==2.9.2
iso8601==0.1.12; sys_platform == "win32" or implementation_name != "cpython"
pycparser==2.20

View File

@ -0,0 +1,43 @@
import os
import re
from setuptools import find_packages, setup
def version():
ver_str_line = open('alice/__init__.py', 'rt').read()
mob = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", ver_str_line, re.M)
if not mob:
raise RuntimeError("Unable to find version string")
return mob.group(1)
with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as f:
long_description = f.read()
def requirements():
return open('requirements.txt', 'rt').read().splitlines()
setup(
name='alice',
version=version(),
description='A database migrations tool for Tortoise-ORM.',
author='long2ice',
long_description_content_type='text/x-rst',
long_description=long_description,
author_email='long2ice@gmail.com',
url='https://github.com/long2ice/alice',
license='MIT License',
packages=find_packages(include=['alice*']),
include_package_data=True,
zip_safe=True,
entry_points={
'console_scripts': ['alice = alice.cli:cli'],
},
platforms='any',
keywords=(
'migrations Tortoise-ORM mysql'
),
install_requires=requirements(),
)

View File

@ -2,17 +2,17 @@ from asynctest import TestCase
from tortoise import Tortoise
from alice.backends.mysql import MysqlDDL
from tests.models import Category
from alice.migrate import Migrate
TORTOISE_ORM = {
'connections': {
'default': 'mysql://root:123456@127.0.0.1:3306/test'
'default': 'mysql://root:123456@127.0.0.1:3306/test',
},
'apps': {
'models': {
'models': ['tests.models'],
'default_connection': 'default',
}
},
}
}
@ -22,6 +22,7 @@ class DBTestCase(TestCase):
await Tortoise.init(config=TORTOISE_ORM)
self.client = Tortoise.get_connection('default')
self.ddl = MysqlDDL(self.client)
self.migrate = Migrate(ddl=self.ddl)
async def tearDown(self) -> None:
await Tortoise.close_connections()

View File

@ -1,5 +1,5 @@
from tests.backends.mysql import DBTestCase
from tests.models import Category, User
from tests.models import Category
class TestDDL(DBTestCase):
@ -47,7 +47,3 @@ class TestDDL(DBTestCase):
def test_drop_fk(self):
ret = self.ddl.drop_fk(Category, Category._meta.fields_map.get('user'))
self.assertEqual(ret, "ALTER TABLE category DROP FOREIGN KEY fk_category_user_366ffa6f")
async def test_aa(self):
user = await User.get(username='test')
await user.save()

View File

@ -0,0 +1,7 @@
from tests.backends.mysql import DBTestCase
class TestMigrate(DBTestCase):
async def test_migrate(self):
self.migrate.diff_models_module('tests.models', 'tests.new_models')
print(self.migrate.operators)

69
tests/new_models.py Normal file
View File

@ -0,0 +1,69 @@
import datetime
from enum import IntEnum
from tortoise import fields, Model
class ProductType(IntEnum):
article = 1
page = 2
class PermissionAction(IntEnum):
create = 1
delete = 2
update = 3
read = 4
class Status(IntEnum):
on = 1
off = 0
class User(Model):
username = fields.CharField(max_length=20, unique=True)
password = fields.CharField(max_length=200)
last_login = fields.DatetimeField(description='Last Login', default=datetime.datetime.now)
is_active = fields.BooleanField(default=True, description='Is Active')
is_superuser = fields.BooleanField(default=False, description='Is SuperUser')
avatar = fields.CharField(max_length=200, default='')
intro = fields.TextField(default='')
def __str__(self):
return f'{self.pk}#{self.username}'
class Category(Model):
slug = fields.CharField(max_length=200)
name = fields.CharField(max_length=200)
user = fields.ForeignKeyField('new_models.User', description='User')
created_at = fields.DatetimeField(auto_now_add=True)
def __str__(self):
return f'{self.pk}#{self.name}'
class Product(Model):
categories = fields.ManyToManyField('new_models.Category')
name = fields.CharField(max_length=50)
view_num = fields.IntField(description='View Num')
sort = fields.IntField()
is_reviewed = fields.BooleanField(description='Is Reviewed')
type = fields.IntEnumField(ProductType, description='Product Type')
image = fields.CharField(max_length=200)
body = fields.TextField()
created_at = fields.DatetimeField(auto_now_add=True)
def __str__(self):
return f'{self.pk}#{self.name}'
class Config(Model):
label = fields.CharField(max_length=200)
key = fields.CharField(max_length=20)
value = fields.JSONField()
status: Status = fields.IntEnumField(Status, default=Status.on)
def __str__(self):
return f'{self.pk}#{self.label}'