Disk Cloner GUI (PyQt6): grafički alat za napredno kloniranje i backup diskova

Disk Cloner GUI (PyQt6) je open-source Python aplikacija koja omogućava jednostavno, ali moćno upravljanje kloniranjem i backupom diskova kroz moderni grafički interfejs. Kreirana u PyQt6 okruženju, ova alatka je posebno namenjena sistem administratorima i tehničarima koji žele da naprave potpunu kopiju diska, kreiraju kompresovani image sistemske particije, ili vrate backup na novi uređaj — sve iz jedne praktične GUI aplikacije.


🔧 Ključne funkcionalnosti

Program kombinuje snagu Linux komandnih alata (kao što su dd, gzip, lsblk, parted, resize2fs, xfs_growfs, itd.) sa jednostavnošću PyQt6 grafičkog interfejsa.
Glavne mogućnosti uključuju:

1. Automatsko otkrivanje diskova

Disk Cloner koristi komandu lsblk u JSON formatu kako bi automatski detektovao sve dostupne uređaje i prikazao ih u listi. Svaki disk se prikazuje sa detaljima poput veličine, modela, mount tačke i tipa fajl sistema.

2. Podešavanje uloga diskova

Korisnik može jednostavno da izabere koji će uređaj biti:

  • Source (matični disk) – izvorni disk sa koga se pravi kopija,
  • External (eksterni HDD) – lokacija za čuvanje kompresovane slike diska,
  • Target (ciljni disk) – uređaj na koji se vraća (restoruje) image.

3. Kreiranje kompresovanog image fajla

Aplikacija omogućava stvaranje gzip-kompresovanog image fajla izvornog diska pomoću dd alata.
Podržava i korišćenje pv alata (ako je instaliran) za vizuelni prikaz napretka u realnom vremenu.
Image se automatski snima u odabrani folder na eksternom disku, sa imenom po izboru korisnika (npr. backup.img.gz).

4. Vraćanje image fajla na ciljni disk

Opcija “Restore image” omogućava dekompresiju image fajla (gunzip) i direktno upisivanje njegovog sadržaja na ciljni disk uz pomoć dd.
Ova operacija potpuno zamenjuje sadržaj ciljnog diska, pa program s pravom upozorava korisnika da su operacije destruktivne.

5. Proširenje root particije nakon kloniranja

Nakon vraćanja image fajla, aplikacija može automatski ili ručno pokrenuti resize particije kako bi iskoristila ceo dostupan prostor na disku.
Podržani su:

  • growpart (za automatsko proširenje particije)
  • parted kao alternativa
  • resize2fs (za EXT fajl sisteme)
  • xfs_growfs (za XFS fajl sisteme)

6. Prikaz logova i napretka u GUI-ju

Sve komande i poruke iz komandne linije se prikazuju u realnom vremenu u log prozoru, zajedno sa vizuelnim indikatorom napretka.
To korisniku omogućava potpunu transparentnost i praćenje svake operacije.


🧠 Napredne opcije

Pored osnovnih funkcionalnosti, program nudi i nekoliko dodatnih opcija koje olakšavaju svakodnevni rad:

  • Odabir foldera za snimanje image fajla putem dijaloga.
  • Odabir postojećeg .img.gz fajla za restore operaciju.
  • Unos root lozinke (opcionalno) za automatsko pokretanje komandi sa sudo privilegijama.
  • Automatsko resize-ovanje particije po završetku restore procesa.

⚙️ Tehnički detalji i preduvjeti

Program koristi standardne Linux alate, pa pre njegovog pokretanja treba osigurati da su instalirani sledeći paketi:

sudo, dd, gzip, gunzip, lsblk, parted, growpart, resize2fs, xfs_growfs, pv

Pokreće se jednostavno iz terminala:

python3 disk_cloner_qt6.py

Preporučuje se pokretanje sa administratorskim privilegijama ili unošenje root lozinke u samom GUI-ju.


⚠️ Bezbednosna napomena

Sve operacije koje Disk Cloner obavlja — kloniranje, upisivanje image fajlova i resize particija — su destruktivne i mogu trajno obrisati podatke sa ciljnog diska.
Pre svake kritične akcije, aplikacija prikazuje jasno upozorenje i traži potvrdu korisnika.


💡 Tehnologije i implementacija

  • Programski jezik: Python 3
  • GUI framework: PyQt6
  • Upravljanje procesima: subprocess i QThread za asinhrono izvršavanje komandi
  • Interaktivnost: Signali i slotovi za ažuriranje logova u realnom vremenu
  • Struktura: Modularna, sa klasama CommandExecutor i DiskCloner koje razdvajaju logiku izvršavanja i interfejsa.

🧰 Prednosti upotrebe

✅ Intuitivan grafički interfejs
✅ Nema potrebe za ručnim unosom komplikovanih dd komandi
✅ Automatsko prepoznavanje diskova i fajl sistema
✅ Podrška za kompresiju i automatsko resize-ovanje
✅ Potpuna kontrola i transparentnost kroz GUI log


Disk Cloner GUI (PyQt6) je izuzetno koristan alat za sve koji rade sa sistemskim diskovima, serverima ili žele da prave brze sigurnosne kopije Linux sistema.
Spoj jednostavnosti i snage komandne linije čini ga odličnim rešenjem i za profesionalce i za entuzijaste koji žele potpunu kontrolu nad procesom kloniranja bez potrebe za terminalom.

Autor: Aleksandar Maričić
Licenca: Open Source (Python / PyQt6)
Platforma: Linux (testirano na Debian/Ubuntu baziranim distribucijama)


programski kod za diskclonerqt6.py

#diskclonerqt6.py
#!/usr/bin/env python3

"""
Disk Cloner GUI (PyQt6)

Funkcionalnosti:
- Otkriva dostupne diskove (lsblk)
- Omogućava izbor: matični (source), eksterni HDD (image storage), ciljni disk (target)
- Napravi kompresovani image sa source -> eksterni HDD (gzip)
- Vrati image sa eksternog HDD -> ciljni disk
- Opcija za proširenje root particije na ciljnog disku (growpart/parted + resize2fs/xfs_growfs)
- Prikazuje log/progress u GUI

NAPOMENA: Operacije su DESTRUKTIVNE. Aplikacija SAMO generiše i pokreće sistemske komande
koje zahtevaju root privilegije. Pokreni program sa odgovarajućim privilegijama ili će
aplikacija pokušati da koristi sudo (biće zatražena lozinka u konzoli).

Preduvjeti na sistemu (instaliraj ako nedostaju):
- sudo, dd, gzip, gunzip
- lsblk, parted, growpart (opciono), resize2fs (za ext) ili xfs_growfs (za XFS)
- pv (opciono, za lepši napredak)

Upotreba: pokreni iz terminala: python3 disk_cloner_qt6.py

Disk Cloner GUI (PyQt6) - sa opcijama za folder, fajl i lozinku

Dodate opcije:
- odabir foldera za snimanje slike
- odabir .img.gz fajla za kloniranje
- unos root lozinke za sudo
"""

#!/usr/bin/env python3

import sys
import os
import shutil
import subprocess
import threading
import time
import json
from functools import partial
from pathlib import Path

from PyQt6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget,
    QPushButton, QFileDialog, QTextEdit, QMessageBox, QLineEdit, QCheckBox, QProgressBar
)
from PyQt6.QtCore import Qt, pyqtSignal, QThread


class CommandExecutor(QThread):
    log_signal = pyqtSignal(str)
    finished_signal = pyqtSignal(bool)

    def __init__(self, cmd):
        super().__init__()
        self.cmd = cmd

    def run(self):
        self.log_signal.emit(f"Pokrećem: {self.cmd}\n")
        try:
            proc = subprocess.Popen(self.cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
            for line in proc.stdout:
                self.log_signal.emit(line)
            proc.wait()
            success = (proc.returncode == 0)
        except Exception as e:
            self.log_signal.emit(f"Greska: {e}\n")
            success = False
        self.finished_signal.emit(success)


class DiskCloner(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Disk Cloner — Qt6")
        self.resize(900, 700)

        self.layout = QVBoxLayout()
        self.setLayout(self.layout)

        # Postavljanje stila
        QApplication.setStyle("Fusion")

        self.disks = []
        self.roles = {"source": None, "external": None, "target": None}

        self._setup_ui()
        self.refresh_disks()

    def _setup_ui(self):
        # Disk lista
        top_layout = QHBoxLayout()
        self.layout.addLayout(top_layout)

        self.lst_disks = QListWidget()
        self.lst_disks.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
        top_layout.addWidget(self._group_box_widget("Detected disks (lsblk)", self.lst_disks))

        controls_layout = QVBoxLayout()
        top_layout.addLayout(controls_layout)

        btn_refresh = QPushButton("Refresh disks")
        btn_refresh.clicked.connect(self.refresh_disks)
        controls_layout.addWidget(btn_refresh)

        # Role buttons
        self.btn_set_source = QPushButton("Set as Source (matični)")
        self.btn_set_ext = QPushButton("Set as External (image storage)")
        self.btn_set_target = QPushButton("Set as Target (ciljni)")
        controls_layout.addWidget(self.btn_set_source)
        controls_layout.addWidget(self.btn_set_ext)
        controls_layout.addWidget(self.btn_set_target)

        self.btn_set_source.clicked.connect(partial(self.set_role_from_selection, "source"))
        self.btn_set_ext.clicked.connect(partial(self.set_role_from_selection, "external"))
        self.btn_set_target.clicked.connect(partial(self.set_role_from_selection, "target"))

        # Labeli za postavljene diskove
        self.lbl_source = QLabel("Source: -")
        self.lbl_external = QLabel("External: -")
        self.lbl_target = QLabel("Target: -")
        controls_layout.addWidget(self.lbl_source)
        controls_layout.addWidget(self.lbl_external)
        controls_layout.addWidget(self.lbl_target)

        # Ulaz za ime fajla
        controls_layout.addSpacing(10)
        controls_layout.addWidget(QLabel("Image filename (on external):"))
        self.image_name = QLineEdit("elementary-os-backup.img.gz")
        controls_layout.addWidget(self.image_name)

        self.chk_resize_after = QCheckBox("Resize root partition on target after restore")
        self.chk_resize_after.setChecked(True)
        controls_layout.addWidget(self.chk_resize_after)

        # Dugo za kreiranje i restore
        controls_layout.addSpacing(10)
        self.btn_create_image = QPushButton("Create compressed image on External")
        self.btn_restore_image = QPushButton("Restore image from External to Target")
        controls_layout.addWidget(self.btn_create_image)
        controls_layout.addWidget(self.btn_restore_image)

        self.btn_create_image.clicked.connect(self.create_image)
        self.btn_restore_image.clicked.connect(self.restore_image)

        # Resize dugme
        controls_layout.addSpacing(10)
        self.btn_resize_now = QPushButton("Resize target root partition now")
        controls_layout.addWidget(self.btn_resize_now)
        self.btn_resize_now.clicked.connect(self.resize_target_root)

        # Dodatne opcije
        self._add_extra_options()

        controls_layout.addStretch()

        # Log i progress
        self.log = QTextEdit()
        self.log.setReadOnly(True)
        self.layout.addWidget(self._group_box_widget("Log/Output", self.log))

        self.progress = QProgressBar()
        self.progress.setRange(0, 0)
        self.progress.hide()
        self.layout.addWidget(self.progress)

    def _group_box_widget(self, title, widget):
        container = QWidget()
        v = QVBoxLayout()
        container.setLayout(v)
        v.addWidget(QLabel(title))
        v.addWidget(widget)
        return container

    def _add_extra_options(self):
        # Folder za snimanje
        folder_layout = QHBoxLayout()
        self.btn_browse_folder = QPushButton("Odaberi folder za snimanje")
        self.lbl_folder_path = QLabel("-")
        folder_layout.addWidget(self.btn_browse_folder)
        folder_layout.addWidget(self.lbl_folder_path)
        self.btn_browse_folder.clicked.connect(self.browse_folder)
        self.layout.addLayout(folder_layout)

        # Fajl za kloniranje
        file_layout = QHBoxLayout()
        self.btn_browse_img = QPushButton("Odaberi .img.gz fajl za kloniranje")
        self.lbl_img_file = QLabel("-")
        file_layout.addWidget(self.btn_browse_img)
        file_layout.addWidget(self.lbl_img_file)
        self.btn_browse_img.clicked.connect(self.browse_img_file)
        self.layout.addLayout(file_layout)

        # Lozinka za sudo
        pw_layout = QHBoxLayout()
        self.lbl_password = QLabel("Root password:")
        self.txt_password = QLineEdit()
        self.txt_password.setEchoMode(QLineEdit.EchoMode.Password)
        pw_layout.addWidget(self.lbl_password)
        pw_layout.addWidget(self.txt_password)
        self.layout.addLayout(pw_layout)

    def browse_folder(self):
        folder = QFileDialog.getExistingDirectory(self, "Odaberi folder za snimanje")
        if folder:
            self.lbl_folder_path.setText(folder)

    def browse_img_file(self):
        filename, _ = QFileDialog.getOpenFileName(self, "Odaberi .img.gz fajl", filter="Gzip Files (*.img.gz)")
        if filename:
            self.lbl_img_file.setText(filename)

    def append_log(self, text: str):
        # Dodavanje teksta u log sa automatskim scroll-ovanjem
        self.log.append(text)
        self.log.verticalScrollBar().setValue(self.log.verticalScrollBar().maximum())

    def run_command(self, cmd):
        # Pokretanje komande u posebnoj niti sa signalima
        self.thread = CommandExecutor(cmd)
        self.thread.log_signal.connect(self.append_log)
        self.thread.finished_signal.connect(self.on_operation_finished)
        self.progress.show()
        self.thread.start()

    def on_operation_finished(self, success: bool):
        self.progress.hide()
        if success:
            self.append_log("== Operacija završena uspešno ==\n")
        else:
            self.append_log("== Operacija završena sa greškama ==\n")

    def refresh_disks(self):
        self.lst_disks.clear()
        self.disks = []
        try:
            out = subprocess.check_output(['lsblk', '-o', 'NAME,SIZE,MODEL,MOUNTPOINT,TYPE,FSTYPE', '-J'], text=True)
            js = json.loads(out)
            for dev in js.get('blockdevices', []):
                if dev.get('type') != 'disk':
                    continue
                name = dev.get('name')
                size = dev.get('size')
                model = dev.get('model') or ''
                mount = dev.get('mountpoint') or ''
                fstype = dev.get('fstype') or ''
                display = f"/dev/{name} — {size} — {model} — mount: {mount} — fstype: {fstype}"
                self.disks.append({'name': name, 'size': size, 'model': model, 'mount': mount, 'fstype': fstype})
                self.lst_disks.addItem(display)
            self.append_log("Disk lista osvežena.")
        except Exception as e:
            self.append_log(f"Ne mogu da pozovem lsblk: {e}")

    def set_role_from_selection(self, role):
        item = self.lst_disks.currentItem()
        if not item:
            QMessageBox.warning(self, "Nije izabrano", "Izaberi disk iz liste prvo.")
            return
        idx = self.lst_disks.currentRow()
        disk = self.disks[idx]
        devpath = f"/dev/{disk['name']}"
        self.roles[role] = devpath
        self.update_role_labels()
        self.append_log(f"Postavljen {role} = {devpath}")

    def update_role_labels(self):
        self.lbl_source.setText(f"Source: {self.roles['source'] or '-'}")
        self.lbl_external.setText(f"External: {self.roles['external'] or '-'}")
        self.lbl_target.setText(f"Target: {self.roles['target'] or '-'}")

    def confirm_danger(self, text):
        msg = QMessageBox(self)
        msg.setWindowTitle("Upozorenje — opasna operacija")
        msg.setText(text)
        msg.setInformativeText("Ove operacije mogu trajno obrisati podatke. Nastaviti?")
        msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
        msg.setDefaultButton(QMessageBox.StandardButton.No)
        ret = msg.exec()
        return ret == QMessageBox.StandardButton.Yes

    def ensure_roles_set(self, need_source=True, need_external=True, need_target=False):
        if need_source and not self.roles['source']:
            QMessageBox.warning(self, "Source nije postavljen", "Postavi source disk (matični) iz liste.")
            return False
        if need_external and not self.roles['external']:
            QMessageBox.warning(self, "External nije postavljen", "Postavi eksterni disk (image storage) iz liste.")
            return False
        if need_target and not self.roles['target']:
            QMessageBox.warning(self, "Target nije postavljen", "Postavi ciljni disk iz liste.")
            return False
        return True

    def create_image(self):
        if not self.ensure_roles_set(need_source=True, need_external=True):
            return
        src = self.roles['source']
        ext = self.roles['external']
        folder = self.lbl_folder_path.text().strip()
        if not folder or not os.path.isdir(folder):
            QMessageBox.warning(self, "Folder", "Odaberi validan folder za snimanje.")
            return
        imgname = self.image_name.text().strip()
        if not imgname:
            QMessageBox.warning(self, "Ime fajla", "Unesi ime image fajla.")
            return
        dest = os.path.join(folder, imgname)

        # Provera
        if os.path.exists(dest):
            if not QMessageBox.question(self, "Datoteka postoji", f"{dest} već postoji. Overwrite?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
                return

        if not self.confirm_danger(f"Napraviti image diska {src} na {dest} ?"):
            return

        # Koristi root password ako je unet
        password = self.txt_password.text()
        sudo_prefix = ""
        if password:
            sudo_prefix = f"echo '{password}' | sudo -S "
        elif os.geteuid() != 0:
            sudo_prefix = "sudo "

        # Komanda za dd + gzip (sa pv ako je dostupan)
        pv = shutil.which('pv')
        gzip_bin = shutil.which('gzip') or 'gzip'

        if pv:
            cmd = f"{sudo_prefix}bash -lc 'dd if={src} bs=64K conv=noerror,sync | {pv} | {gzip_bin} -c > \"{dest}\"'"
        else:
            cmd = f"{sudo_prefix}bash -lc 'dd if={src} bs=64K conv=noerror,sync | {gzip_bin} -c > \"{dest}\"'"

        self.append_log(f"Kreira se image: {dest}\nOcekivano vreme: zavisi od velicine i brzine diskova.")
        self.run_command(cmd)

    def restore_image(self):
        if not self.ensure_roles_set(need_source=False, need_external=True, need_target=True):
            return
        ext = self.roles['external']
        target = self.roles['target']
        srcfile = self.lbl_img_file.text().strip()
        if not srcfile or not os.path.exists(srcfile):
            QMessageBox.warning(self, "Fajl ne postoji", f"Fajl {srcfile} ne postoji na eksternom disku.")
            return

        if not self.confirm_danger(f"Restore {srcfile} -> {target} ? Svi podaci na {target} bice izbrisani."):
            return

        password = self.txt_password.text()
        sudo_prefix = ""
        if password:
            sudo_prefix = f"echo '{password}' | sudo -S "
        elif os.geteuid() != 0:
            sudo_prefix = "sudo "

        # Komanda za gunzip + dd
        gzip_path = shutil.which('gzip') or 'gzip'
        cmd = f"{sudo_prefix}bash -lc '{gzip_path} -d -c \"{srcfile}\" | dd of={target} bs=64K conv=noerror,sync'"
        self.append_log(f"Restorujem image {srcfile} na {target}")
        self.run_command(cmd)

        # Ako je opcija za resize nakon restoriranja
        if self.chk_resize_after.isChecked():
            def delayed_resize():
                time.sleep(3)
                self.append_log("Automatsko pokretanje resize operacije nakon restor-a (moze potrajati)...")
                self.resize_target_root()
            t = threading.Timer(2.0, delayed_resize)
            t.start()

    def resize_target_root(self):
        if not self.roles['target']:
            QMessageBox.warning(self, "Target nije postavljen", "Postavi ciljni disk iz liste.")
            return
        target = self.roles['target']
        try:
            out = subprocess.check_output(['lsblk', '-o', 'NAME,MOUNTPOINT,FSTYPE', '-J', target], text=True)
            js = json.loads(out)
            blockdevices = js.get('blockdevices', [])
            if not blockdevices:
                self.append_log('Nema particija na target ili target nije prepoznat.')
                return
            parts = blockdevices[0].get('children', [])
            if not parts:
                self.append_log('Nema particija na target ili target nije prepoznat.')
                return
            # Traži particiju mounted kao /
            root_part = None
            for p in parts:
                if p.get('mountpoint') == '/':
                    root_part = p
                    break
            if not root_part:
                # ili najveća
                parts_sorted = sorted(parts, key=lambda x: self._size_str_to_bytes(x.get('size', '0')), reverse=True)
                root_part = parts_sorted[0]
            part_name = root_part['name']
            part_path = f"/dev/{part_name}"
            fstype = root_part.get('fstype') or ''
        except Exception as e:
            self.append_log(f"Greska pri detekciji particija target: {e}")
            return

        if not self.confirm_danger(f"Prosiriti particiju {part_path} da iskoristi ceo slobodan prostor na disku {target}? \nOperacija moze potrajati."):
            return

        password = self.txt_password.text()
        sudo_prefix = ""
        if password:
            sudo_prefix = f"echo '{password}' | sudo -S "
        elif os.geteuid() != 0:
            sudo_prefix = "sudo "

        # Pokušaj growpart
        growpart_path = shutil.which('growpart')
        if growpart_path:
            import re
            m = re.match(r".*/([a-zA-Z]+)(\d+)$", part_path)
            if m:
                diskbase = m.group(1)
                partnum = m.group(2)
                diskdev = part_path.replace(partnum, '')
                cmd_grow = f"{sudo_prefix}bash -lc '{growpart_path} {diskdev} {partnum} && sleep 1'"
                # resize fs
                if fstype.startswith('ext'):
                    cmd_resizefs = f"{sudo_prefix}bash -lc 'resize2fs {part_path}'"
                elif fstype == 'xfs':
                    mountpoint = f"/mnt/_tmp_resize_{part_name}"
                    os.makedirs(mountpoint, exist_ok=True)
                    cmd_resizefs = f"{sudo_prefix}bash -lc 'mount {part_path} {mountpoint} || true; xfs_growfs {mountpoint}; umount {mountpoint} || true'"
                else:
                    cmd_resizefs = None
                if cmd_resizefs:
                    cmd = cmd_grow + " && " + cmd_resizefs
                else:
                    cmd = cmd_grow
                self.append_log(f"Koristim growpart -> {cmd}")
                self.run_command(cmd)
                return

        # Alternativa: parted resize
        import re
        m = re.match(r".*/([a-zA-Z]+)(\d+)$", part_path)
        if m:
            diskdev = part_path.replace(m.group(2), '')
            partnum = m.group(2)
            cmd_parts = []
            cmd_parts.append(f"{sudo_prefix}bash -lc 'parted -s {diskdev} resizepart {partnum} 100%' || true'")
            if fstype.startswith('ext'):
                cmd_parts.append(f"{sudo_prefix}bash -lc 'resize2fs {part_path}' || true'")
            elif fstype == 'xfs':
                mountpoint = f"/mnt/_tmp_resize_{part_name}"
                os.makedirs(mountpoint, exist_ok=True)
                cmd_parts.append(f"{sudo_prefix}bash -lc 'mount {part_path} {mountpoint} || true; xfs_growfs {mountpoint} || true; umount {mountpoint} || true'")
            cmd = ' && '.join(cmd_parts)
            self.append_log(f"Pokrecem resize secvencre: {cmd}")
            self.run_command(cmd)
        else:
            self.append_log('Ne mogu da odredim partition number za resize.')

    def _size_str_to_bytes(self, s: str) -> int:
        if not s:
            return 0
        s = s.strip().upper()
        units = {'K': 1024, 'M': 1024**2, 'G': 1024**3, 'T': 1024**4}
        try:
            if s[-1] in units:
                return int(float(s[:-1]) * units[s[-1]])
            return int(s)
        except Exception:
            return 0


if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = DiskCloner()
    win.show()
    sys.exit(app.exec())

#MIT_License.txt
MIT License

Copyright (c) [2025] [Aleksandar Maričić]

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. 

By Abel

Leave a Reply

Your email address will not be published. Required fields are marked *