ClickMigrate 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ """ClickMigrate - A database migration framework for ClickHouse."""
2
+
3
+ from clickmigrate.manager import MigrationManager
4
+ from clickmigrate.config import Config
5
+
6
+ __all__ = ["MigrationManager", "Config"]
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m clickmigrate."""
2
+
3
+ from clickmigrate.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
clickmigrate/cli.py ADDED
@@ -0,0 +1,84 @@
1
+ """Command Line Interface for ClickMigrate."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from clickmigrate.config import load_config
7
+ from clickmigrate.manager import MigrationManager
8
+ from clickmigrate.exceptions import ClickMigrateError
9
+
10
+ app = typer.Typer(help="ClickMigrate: A modern ClickHouse migration framework.")
11
+ console = Console()
12
+
13
+ def get_manager() -> MigrationManager:
14
+ try:
15
+ config = load_config()
16
+ return MigrationManager(config)
17
+ except Exception as e:
18
+ console.print(f"[bold red]Initialization Error:[/bold red] {e}")
19
+ raise typer.Exit(code=1)
20
+
21
+ @app.command()
22
+ def init() -> None:
23
+ """Initialize a new ClickMigrate environment."""
24
+ config = load_config()
25
+ import os
26
+ os.makedirs(config.migration_directory, exist_ok=True)
27
+ console.print(f"[green]Initialized ClickMigrate in ./{config.migration_directory}/[/green]")
28
+
29
+ @app.command()
30
+ def revision(message: str = typer.Option(..., "-m", "--message", help="Migration description")) -> None:
31
+ """Create a new migration file."""
32
+ manager = get_manager()
33
+ filepath = manager.create_revision(message)
34
+ console.print(f"[green]Created revision:[/green] {filepath}")
35
+
36
+ @app.command()
37
+ def migrate(dry_run: bool = typer.Option(False, "--dry-run", help="Simulate migration without applying")) -> None:
38
+ """Apply all pending migrations."""
39
+ console.print("\n[bold]ClickMigrate[/bold]\n")
40
+ manager = get_manager()
41
+
42
+ try:
43
+ applied_count, pending_count = manager.status()
44
+ if pending_count == 0:
45
+ console.print("No pending migrations.\n")
46
+ return
47
+
48
+ console.print(f"Applying [yellow]{pending_count}[/yellow] migrations...\n")
49
+
50
+ if dry_run:
51
+ console.print("[yellow]DRY RUN: No changes will be made to the database.[/yellow]\n")
52
+
53
+ applied, duration = manager.migrate(dry_run=dry_run)
54
+
55
+ console.print("\n[bold green]SUCCESS[/bold green]\n")
56
+ console.print(f"Applied migrations : {applied}")
57
+ console.print(f"Pending migrations : {pending_count - applied}")
58
+ console.print(f"Execution time : {duration:.2f} seconds\n")
59
+
60
+ except ClickMigrateError as e:
61
+ console.print(f"\n[bold red]FAILED[/bold red]\n{e}")
62
+ raise typer.Exit(code=1)
63
+
64
+ @app.command()
65
+ def status() -> None:
66
+ """Show current migration status."""
67
+ manager = get_manager()
68
+ applied, pending = manager.status()
69
+ console.print(f"Applied migrations : [green]{applied}[/green]")
70
+ console.print(f"Pending migrations : [yellow]{pending}[/yellow]")
71
+
72
+ @app.command()
73
+ def validate() -> None:
74
+ """Validate checksums of applied migrations against local files."""
75
+ manager = get_manager()
76
+ try:
77
+ manager.validate()
78
+ console.print("[green]All migrations are valid. No checksum mismatches.[/green]")
79
+ except ClickMigrateError as e:
80
+ console.print(f"[bold red]Validation Failed:[/bold red] {e}")
81
+ raise typer.Exit(code=1)
82
+
83
+ if __name__ == "__main__":
84
+ app()
clickmigrate/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """Configuration management for ClickMigrate."""
2
+
3
+ import os
4
+ import json
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict
8
+ import yaml
9
+ from clickmigrate.exceptions import ConfigurationError
10
+
11
+ @dataclass
12
+ class Config:
13
+ """Stores the ClickMigrate configuration."""
14
+ host: str = "localhost"
15
+ port: int = 8123
16
+ database: str = "default"
17
+ username: str = "default"
18
+ password: str = ""
19
+ migration_directory: str = "migrations"
20
+ migration_table: str = "clickmigrate_history"
21
+
22
+ def load_config() -> Config:
23
+ """Loads configuration from environment variables or files."""
24
+ config_data: Dict[str, Any] = {}
25
+
26
+ # 1. Try pyproject.toml
27
+ if os.path.exists("pyproject.toml"):
28
+ with open("pyproject.toml", "rb") as f:
29
+ toml_data = tomllib.load(f)
30
+ config_data.update(toml_data.get("tool", {}).get("clickmigrate", {}))
31
+
32
+ # 2. Try clickmigrate.json
33
+ elif os.path.exists("clickmigrate.json"):
34
+ with open("clickmigrate.json", "r") as f:
35
+ config_data.update(json.load(f))
36
+
37
+ # 3. Try clickmigrate.yaml
38
+ elif os.path.exists("clickmigrate.yaml"):
39
+ with open("clickmigrate.yaml", "r") as f:
40
+ config_data.update(yaml.safe_load(f) or {})
41
+
42
+ # 4. Override with Environment Variables
43
+ env_mapping = {
44
+ "CLICKMIGRATE_HOST": "host",
45
+ "CLICKMIGRATE_PORT": "port",
46
+ "CLICKMIGRATE_DATABASE": "database",
47
+ "CLICKMIGRATE_USERNAME": "username",
48
+ "CLICKMIGRATE_PASSWORD": "password",
49
+ "CLICKMIGRATE_DIRECTORY": "migration_directory",
50
+ "CLICKMIGRATE_TABLE": "migration_table",
51
+ }
52
+
53
+ for env_var, key in env_mapping.items():
54
+ if env_var in os.environ:
55
+ val = os.environ[env_var]
56
+ config_data[key] = int(val) if key == "port" else val
57
+
58
+ return Config(**config_data)
@@ -0,0 +1,66 @@
1
+ """Database interaction layer."""
2
+
3
+ import clickhouse_connect
4
+ from clickhouse_connect.driver.client import Client
5
+ from typing import List, Dict, Any
6
+ from clickmigrate.config import Config
7
+ from clickmigrate.exceptions import MigrationError
8
+
9
+ class Database:
10
+ """Handles ClickHouse connections and queries."""
11
+
12
+ def __init__(self, config: Config) -> None:
13
+ self.config = config
14
+ self.client: Client = self._connect()
15
+
16
+ def _connect(self) -> Client:
17
+ """Establishes a connection to ClickHouse."""
18
+ try:
19
+ return clickhouse_connect.get_client(
20
+ host=self.config.host,
21
+ port=self.config.port,
22
+ username=self.config.username,
23
+ password=self.config.password,
24
+ database=self.config.database,
25
+ )
26
+ except Exception as e:
27
+ raise MigrationError(f"Failed to connect to ClickHouse: {e}")
28
+
29
+ def ensure_history_table(self) -> None:
30
+ """Creates the migration history table if it doesn't exist."""
31
+ query = f"""
32
+ CREATE TABLE IF NOT EXISTS {self.config.migration_table} (
33
+ version String,
34
+ name String,
35
+ checksum String,
36
+ applied_at DateTime DEFAULT now()
37
+ ) ENGINE = MergeTree()
38
+ ORDER BY version
39
+ """
40
+ self.client.command(query)
41
+
42
+ def get_applied_migrations(self) -> Dict[str, str]:
43
+ """Retrieves applied migrations and their checksums.
44
+
45
+ Returns:
46
+ Dict mapping version to checksum.
47
+ """
48
+ self.ensure_history_table()
49
+ query = f"SELECT version, checksum FROM {self.config.migration_table}"
50
+ result = self.client.query(query)
51
+ return {row[0]: row[1] for row in result.result_rows}
52
+
53
+ def apply_migration(self, version: str, name: str, checksum: str, sql: str) -> None:
54
+ """Executes a migration script and records it in the history table."""
55
+ try:
56
+ # Execute the actual migration
57
+ self.client.command(sql)
58
+
59
+ # Record in history
60
+ record_query = f"""
61
+ INSERT INTO {self.config.migration_table} (version, name, checksum)
62
+ VALUES ('{version}', '{name}', '{checksum}')
63
+ """
64
+ self.client.command(record_query)
65
+ except Exception as e:
66
+ raise MigrationError(f"Failed to apply migration {version}_{name}: {e}")
@@ -0,0 +1,17 @@
1
+ """Custom exceptions for ClickMigrate."""
2
+
3
+ class ClickMigrateError(Exception):
4
+ """Base exception for all ClickMigrate errors."""
5
+ pass
6
+
7
+ class ConfigurationError(ClickMigrateError):
8
+ """Raised when configuration is invalid or missing."""
9
+ pass
10
+
11
+ class MigrationError(ClickMigrateError):
12
+ """Raised when a migration fails to apply."""
13
+ pass
14
+
15
+ class ChecksumError(ClickMigrateError):
16
+ """Raised when an applied migration's checksum differs from the file."""
17
+ pass
@@ -0,0 +1,116 @@
1
+ """Core migration management logic."""
2
+
3
+ import os
4
+ import hashlib
5
+ import time
6
+ from typing import List, Tuple
7
+ from dataclasses import dataclass
8
+ from clickmigrate.config import Config
9
+ from clickmigrate.database import Database
10
+ from clickmigrate.exceptions import ChecksumError, ClickMigrateError
11
+
12
+ @dataclass
13
+ class Migration:
14
+ """Represents a single SQL migration."""
15
+ version: str
16
+ name: str
17
+ filepath: str
18
+ content: str
19
+ checksum: str
20
+
21
+ class MigrationManager:
22
+ """API for managing ClickHouse migrations."""
23
+
24
+ def __init__(self, config: Config) -> None:
25
+ self.config = config
26
+ self.db = Database(config)
27
+
28
+ def _calculate_checksum(self, content: str) -> str:
29
+ """Calculates SHA-256 checksum for migration content."""
30
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
31
+
32
+ def _get_local_migrations(self) -> List[Migration]:
33
+ """Discovers and parses local .sql migration files."""
34
+ migrations = []
35
+ if not os.path.exists(self.config.migration_directory):
36
+ return migrations
37
+
38
+ for filename in sorted(os.listdir(self.config.migration_directory)):
39
+ if filename.endswith(".sql"):
40
+ parts = filename.replace(".sql", "").split("_", 1)
41
+ if len(parts) != 2:
42
+ continue
43
+
44
+ version, name = parts
45
+ filepath = os.path.join(self.config.migration_directory, filename)
46
+
47
+ with open(filepath, "r", encoding="utf-8") as f:
48
+ content = f.read()
49
+
50
+ checksum = self._calculate_checksum(content)
51
+ migrations.append(Migration(version, name, filepath, content, checksum))
52
+
53
+ return migrations
54
+
55
+ def validate(self) -> None:
56
+ """Validates that local checksums match applied checksums."""
57
+ applied = self.db.get_applied_migrations()
58
+ local = self._get_local_migrations()
59
+
60
+ for loc in local:
61
+ if loc.version in applied:
62
+ if applied[loc.version] != loc.checksum:
63
+ raise ChecksumError(
64
+ f"Checksum mismatch for {loc.version}_{loc.name}. "
65
+ "The local file has been modified since it was applied."
66
+ )
67
+
68
+ def status(self) -> Tuple[int, int]:
69
+ """Returns the count of (applied, pending) migrations."""
70
+ applied = self.db.get_applied_migrations()
71
+ local = self._get_local_migrations()
72
+ pending_count = sum(1 for m in local if m.version not in applied)
73
+ return len(applied), pending_count
74
+
75
+ def migrate(self, dry_run: bool = False) -> Tuple[int, float]:
76
+ """Applies all pending migrations.
77
+
78
+ Returns:
79
+ Tuple containing (number of migrations applied, execution time in seconds).
80
+ """
81
+ self.validate()
82
+ applied_map = self.db.get_applied_migrations()
83
+ local = self._get_local_migrations()
84
+
85
+ pending = [m for m in local if m.version not in applied_map]
86
+
87
+ if dry_run or not pending:
88
+ return len(pending), 0.0
89
+
90
+ start_time = time.time()
91
+ for migration in pending:
92
+ self.db.apply_migration(
93
+ version=migration.version,
94
+ name=migration.name,
95
+ checksum=migration.checksum,
96
+ sql=migration.content
97
+ )
98
+
99
+ return len(pending), time.time() - start_time
100
+
101
+ def create_revision(self, message: str) -> str:
102
+ """Creates a new empty migration file."""
103
+ os.makedirs(self.config.migration_directory, exist_ok=True)
104
+ local = self._get_local_migrations()
105
+
106
+ next_version = 1 if not local else int(local[-1].version) + 1
107
+ version_str = f"{next_version:03d}"
108
+
109
+ safe_name = message.lower().replace(" ", "_").replace("-", "_")
110
+ filename = f"{version_str}_{safe_name}.sql"
111
+ filepath = os.path.join(self.config.migration_directory, filename)
112
+
113
+ with open(filepath, "w", encoding="utf-8") as f:
114
+ f.write(f"-- Migration: {message}\n-- Version: {version_str}\n\n")
115
+
116
+ return filepath
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: ClickMigrate
3
+ Version: 1.0.0
4
+ Summary: A modern, simple database migration framework for ClickHouse.
5
+ Author-email: Ivo Theis <ivo.theis@queueforge.dev>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: clickhouse-connect>=0.6.8
11
+ Requires-Dist: typer>=0.9.0
12
+ Requires-Dist: rich>=13.0.0
13
+ Requires-Dist: pyyaml>=6.0.1
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
16
+ Requires-Dist: black>=23.0.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
18
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
19
+ Requires-Dist: build>=1.0.0; extra == "dev"
20
+ Requires-Dist: twine>=4.0.0; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # ClickMigrate
24
+
25
+ A modern, simple, and reliable database migration framework for ClickHouse, inspired by Alembic.
26
+
27
+ ClickMigrate is designed to be lightweight and easy to understand. It avoids over-engineered abstractions, offering a straightforward CLI and a clean Python API for managing your ClickHouse schema evolutions.
28
+
29
+ ---
30
+
31
+ ## Features
32
+
33
+ - **SQL-based Migrations** – Write your migrations in plain `.sql` files.
34
+ - **Automatic Ordering** – Lexicographical sorting ensures migrations run in the correct sequence.
35
+ - **State Management** – Automatically creates and manages a migration history table in ClickHouse.
36
+ - **Checksum Validation** – Validates SHA-256 checksums to detect modified applied migrations.
37
+ - **Flexible Configuration** – Supports `pyproject.toml`, JSON, YAML, or environment variables.
38
+ - **Dry-Run Mode** – Preview which migrations will be applied without altering the database.
39
+ - **Python API & CLI** – Use ClickMigrate from your terminal or programmatically in Python.
40
+
41
+ ---
42
+
43
+ # Installation
44
+
45
+ ClickMigrate requires **Python 3.11+**.
46
+
47
+ Install it using pip:
48
+
49
+ ```bash
50
+ pip install ClickMigrate
51
+ ```
52
+
53
+ ---
54
+
55
+ # Quick Start (CLI)
56
+
57
+ ClickMigrate provides an Alembic-like CLI for managing your migration workflow.
58
+
59
+ ## 1. Initialize the Environment
60
+
61
+ Create the migration directory (default: `migrations/`).
62
+
63
+ ```bash
64
+ clickmigrate init
65
+ ```
66
+
67
+ ---
68
+
69
+ ## 2. Create a Revision
70
+
71
+ Generate a new sequential SQL migration file.
72
+
73
+ ```bash
74
+ clickmigrate revision -m "create users table"
75
+ ```
76
+
77
+ Example output:
78
+
79
+ ```text
80
+ migrations/
81
+ └── 001_create_users_table.sql
82
+ ```
83
+
84
+ Edit the generated file and add your ClickHouse SQL statements.
85
+
86
+ ---
87
+
88
+ ## 3. Check Status
89
+
90
+ View how many migrations have been applied and how many are pending.
91
+
92
+ ```bash
93
+ clickmigrate status
94
+ ```
95
+
96
+ ---
97
+
98
+ ## 4. Apply Migrations
99
+
100
+ Run all pending migrations.
101
+
102
+ ```bash
103
+ clickmigrate migrate
104
+ ```
105
+
106
+ Preview the execution without applying changes:
107
+
108
+ ```bash
109
+ clickmigrate migrate --dry-run
110
+ ```
111
+
112
+ ---
113
+
114
+ ## 5. Validate Migration Integrity
115
+
116
+ Verify that previously applied migration files have not been modified.
117
+
118
+ ```bash
119
+ clickmigrate validate
120
+ ```
121
+
122
+ ---
123
+
124
+ # Configuration
125
+
126
+ ClickMigrate automatically searches for configuration files in your project root.
127
+
128
+ Supported formats (in priority order):
129
+
130
+ 1. `pyproject.toml` *(recommended)*
131
+ 2. `clickmigrate.json`
132
+ 3. `clickmigrate.yaml`
133
+ 4. Environment variables
134
+
135
+ ## Option A: `pyproject.toml` (Recommended)
136
+
137
+ ```toml
138
+ [tool.clickmigrate]
139
+ host = "localhost"
140
+ port = 8123
141
+ database = "default"
142
+ username = "default"
143
+ password = "your_secure_password"
144
+
145
+ migration_directory = "migrations"
146
+ migration_table = "clickmigrate_history"
147
+ ```
148
+
149
+ ## Option B: `clickmigrate.json`
150
+
151
+ ```json
152
+ {
153
+ "host": "localhost",
154
+ "port": 8123,
155
+ "database": "default",
156
+ "username": "default",
157
+ "password": "your_secure_password",
158
+ "migration_directory": "migrations",
159
+ "migration_table": "clickmigrate_history"
160
+ }
161
+ ```
162
+
163
+ ## Option C: `clickmigrate.yaml`
164
+
165
+ ```yaml
166
+ host: localhost
167
+ port: 8123
168
+ database: default
169
+ username: default
170
+ password: your_secure_password
171
+
172
+ migration_directory: migrations
173
+ migration_table: clickmigrate_history
174
+ ```
175
+
176
+ ## Option D: Environment Variables
177
+
178
+ ```bash
179
+ CLICKMIGRATE_HOST=localhost
180
+ CLICKMIGRATE_PORT=8123
181
+ CLICKMIGRATE_DATABASE=default
182
+ CLICKMIGRATE_USERNAME=default
183
+ CLICKMIGRATE_PASSWORD=your_secure_password
184
+
185
+ CLICKMIGRATE_MIGRATION_DIRECTORY=migrations
186
+ CLICKMIGRATE_MIGRATION_TABLE=clickmigrate_history
187
+ ```
188
+
189
+ ---
190
+
191
+ # Migration Naming
192
+
193
+ Migration files are executed in **lexicographical order**.
194
+
195
+ Example:
196
+
197
+ ```text
198
+ 001_create_users.sql
199
+ 002_add_email.sql
200
+ 003_create_orders.sql
201
+ 004_add_indexes.sql
202
+ ```
203
+
204
+ Each migration is executed only once and recorded in the migration history table.
205
+
206
+ ---
207
+
208
+ # Commands
209
+
210
+ | Command | Description |
211
+ |---------|-------------|
212
+ | `clickmigrate init` | Initialize a migration project |
213
+ | `clickmigrate revision -m "message"` | Create a new migration |
214
+ | `clickmigrate status` | Show migration status |
215
+ | `clickmigrate migrate` | Apply pending migrations |
216
+ | `clickmigrate migrate --dry-run` | Preview pending migrations |
217
+ | `clickmigrate validate` | Validate migration checksums |
218
+
219
+ ---
220
+
221
+ # Requirements
222
+
223
+ - Python **3.11+**
224
+ - A running ClickHouse server
225
+ - HTTP interface enabled (default port `8123`)
226
+
227
+ ---
228
+
229
+ # License
230
+
231
+ MIT License.
@@ -0,0 +1,13 @@
1
+ clickmigrate/__init__.py,sha256=W9L3vJ6QTpOKpaJ8vSI3kBn7yFFSyegPer-MmAGhHfQ,199
2
+ clickmigrate/__main__.py,sha256=N5jHrekzEOTzsitMBC0HrRgFRj0m7QGhjWX3rob_Kt0,117
3
+ clickmigrate/cli.py,sha256=XB7OujASlvfwysHbMtaJiNg9ljYf3_B4A09NCEs0j5o,3073
4
+ clickmigrate/config.py,sha256=qetD53iDWE6OFy0k3_R1CsYF6VwFSxEUjQ1Uc94T6DY,1857
5
+ clickmigrate/database.py,sha256=yyd0u62bG5-t2S74PU8RogJAjtNVayVdrim-w_ESRoU,2432
6
+ clickmigrate/exceptions.py,sha256=euhT0gqSD7aTvNpRgZ3E0U3m-qi64bxSHHKvh--o8tA,483
7
+ clickmigrate/manager.py,sha256=XMo8bspdAmg0zYzidaotiLJexy0iMbjDGWpsTTtBS9s,4247
8
+ clickmigrate-1.0.0.dist-info/licenses/LICENSE,sha256=xPxPHPlyTJx-zJ4DrnQWVXHrgoIjA2hjqb4vhNFS2WQ,1067
9
+ clickmigrate-1.0.0.dist-info/METADATA,sha256=YtXlOR6lDp7U_F3DEB07-Bv9UBHydrSEpJQfQIs07Ec,4937
10
+ clickmigrate-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ clickmigrate-1.0.0.dist-info/entry_points.txt,sha256=W_2niTaw0eCwaUZPGOCsmOGM6xx3F7YgbunNeohUgzU,54
12
+ clickmigrate-1.0.0.dist-info/top_level.txt,sha256=Oh2PkpdAo7vLsjoNm7AHbnmBAcSefTKRKLoijcyTm-Q,13
13
+ clickmigrate-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clickmigrate = clickmigrate.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QueueForge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ clickmigrate