Skip to content

Commit

Permalink
CI with GitHub workflows #179:
Browse files Browse the repository at this point in the history
- support type checks with pyright
- add github workflow for lint/typecheck/test
- configure inclusion/exclusion rules for black
- can now be run from the root dir
- move all downloaded models to one folder for caching
- adds cache to github workflow
- add dependencies for type checking
- fix type check issues with latest PyQt5 module
- fix download_models minimal missing control nets
- ignore windows-only constant in type checks
- move image out of LFS for CI tests it's tiny, and the rest of the images aren't needed at the moment
  • Loading branch information
Acly committed Dec 5, 2023
1 parent 3522e1b commit cf127d8
Show file tree
Hide file tree
Showing 27 changed files with 159 additions and 81 deletions.
52 changes: 52 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Lint, Typecheck and Test
on: [push, pull_request]

jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
submodules: true
- name: Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: 'pip'
- name: Dependencies
run: pip install -r requirements.txt
- name: Typecheck
uses: jakebailey/pyright-action@v1
with:
pylance-version: latest-release
- name: Lint
if: ${{ !cancelled() }}
uses: psf/black@stable

test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
submodules: true
- name: Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: 'pip'
- name: Dependencies
run: pip install -r requirements.txt
- name: Cache models
uses: actions/cache@v3
with:
path: scripts/docker/downloads
key: models-v1
- name: Download models
run: python scripts/download_models.py --minimal scripts/docker/downloads
- name: Test installer
run: python -m pytest tests/test_server.py -vs --test-install
- name: Test
run: python -m pytest tests -vs --ci

3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
.server
.vscode
__pycache__
scripts/docker/models
scripts/docker/custom_nodes/*
scripts/docker/downloads
tests/.results
settings.json
ai_diffusion/styles/*
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The easiest way to run a development version of the plugin is to use symlinks:

### Code formatting

The codebase uses [black](https://github.com/psf/black) for formatting. The project root contains a `pyproject.toml` to configure the line length, it should be picked up automatically.
The codebase uses [black](https://github.com/psf/black) for formatting. You can check locally by running `black` in the repository root, or use an IDE integration.

### Code style

Expand All @@ -37,7 +37,9 @@ Code style follows the official Python recommendations. Only exception: no `ALL_

Type annotations should be used where types can't be inferred. Basic type checks are enabled for the project and should not report errors.

The `Krita` module is special in that it is usually only available when running inside Krita. To make type checking work, include `scripts/typeshed` in your `PYTHONPATH`.
The `Krita` module is special in that it is usually only available when running inside Krita. To make type checking work an interface file is located in `scripts/typeshed`.

You can run `pyright` from the repository root to perform type checks on the entire codebase. This is also done by the CI.

Configuration for VSCode with Pylance (.vscode/settings.json):
```
Expand Down Expand Up @@ -77,9 +79,9 @@ Everything else has tests. Mostly. If effort is reasonable, tests are expected.

Testing changes to the installer is annoying because of the file sizes involved. There are some things that help. You can preload model files with the following script:
```
python scripts/download_models.py --minimal scripts/docker
python scripts/download_models.py --minimal scripts/docker/downloads
```
This will download the minimum required models and store them in `scripts/docker` (used as default location because that way the docker build script can use them too).
This will download the minimum required models and store them in `scripts/docker/downloads` (used as default location because that way the docker build script can use them too).

The following command does some automated testing for installation and upgrade. It starts a local file server which pulls preloaded models, so it's reasonably fast and doesn't download the entire internet.
```
Expand Down
8 changes: 3 additions & 5 deletions ai_diffusion/attention_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
def select_current_parenthesis_block(
text: str, cursor_pos: int, open_bracket: str, close_bracket: str
) -> Tuple[int, int] | None:
"""Select the current parenthesis block that the cursor points to. """
"""Select the current parenthesis block that the cursor points to."""
# Ensure cursor position is within valid range
cursor_pos = max(0, min(cursor_pos, len(text)))

Expand Down Expand Up @@ -50,9 +50,8 @@ def select_current_word(text: str, cursor_pos: int) -> Tuple[int, int]:

def select_on_cursor_pos(text: str, cursor_pos: int) -> Tuple[int, int]:
"""Return a range in the text based on the cursor_position."""
return (
select_current_parenthesis_block(text, cursor_pos, "(", ")")
or select_current_word(text, cursor_pos)
return select_current_parenthesis_block(text, cursor_pos, "(", ")") or select_current_word(
text, cursor_pos
)


Expand Down Expand Up @@ -143,4 +142,3 @@ def edit_attention(text: str, positive: bool) -> str:
if weight == 1.0
else f"{open_bracket}{attention_string}:{weight:.1f}{close_bracket}"
)

6 changes: 3 additions & 3 deletions ai_diffusion/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ class Client:
lcm_model: dict[SDVersion, str | None]
supported_sd_versions: list[SDVersion]
device_info: DeviceInfo
nodes_required_inputs: dict[str, dict[str, list[str | list | dict]]] = {}
nodes_inputs: dict[str, dict[str, list[str | list | dict]]] = {}

@staticmethod
async def connect(url=default_url):
Expand Down Expand Up @@ -164,7 +164,7 @@ async def connect(url=default_url):
client.ip_adapter_model = {
ver: _find_ip_adapter(ip, ver) for ver in [SDVersion.sd15, SDVersion.sdxl]
}
client.nodes_required_inputs["IPAdapterApply"] = nodes["IPAdapterApply"]["input"]["required"]
client.nodes_inputs["IPAdapterApply"] = nodes["IPAdapterApply"]["input"]["required"]

# Retrieve upscale models
client.upscalers = nodes["UpscaleModelLoader"]["input"]["required"]["model_name"][0]
Expand Down Expand Up @@ -395,7 +395,7 @@ def _check_workload(self, sdver: SDVersion) -> list[MissingResource]:

def parse_url(url: str):
url = url.strip("/")
url = url.replace('0.0.0.0', '127.0.0.1')
url = url.replace("0.0.0.0", "127.0.0.1")
if not url.startswith("http"):
url = f"http://{url}"
return url
Expand Down
5 changes: 4 additions & 1 deletion ai_diffusion/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ def make_opaque(self, background=Qt.GlobalColor.white):
@property
def data(self):
ptr = self._qimage.bits()
assert ptr is not None, "Accessing data of invalid image"
ptr.setsize(self._qimage.byteCount())
return QByteArray(ptr.asstring())

Expand All @@ -272,7 +273,9 @@ def to_array(self):
import numpy as np

w, h = self.extent
ptr = self._qimage.constBits().asarray(w * h * 4)
bits = self._qimage.constBits()
assert bits is not None, "Accessing data of invalid image"
ptr = bits.asarray(w * h * 4)
array = np.frombuffer(ptr, np.uint8).reshape(w, h, 4) # type: ignore
return array.astype(np.float32) / 255

Expand Down
1 change: 1 addition & 0 deletions ai_diffusion/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ async def _try_download(network: QNetworkAccessManager, url: str, path: Path):
log.info(f"Found {path}.part, resuming download from {out_file.size()} bytes")
request.setRawHeader(b"Range", f"bytes={out_file.size()}-".encode("utf-8"))
reply = network.get(request)
assert reply is not None, f"Network request for {url} failed: reply is None"

progress_future = asyncio.get_running_loop().create_future()
finished_future = asyncio.get_running_loop().create_future()
Expand Down
2 changes: 1 addition & 1 deletion ai_diffusion/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@


_exe = ".exe" if is_windows else ""
_process_flags = subprocess.CREATE_NO_WINDOW if is_windows else 0
_process_flags = subprocess.CREATE_NO_WINDOW if is_windows else 0 # type: ignore


class ServerState(Enum):
Expand Down
12 changes: 7 additions & 5 deletions ai_diffusion/ui/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ..model import Model
from ..root import root
from ..settings import settings
from ..util import ensure
from . import theme
from .widget import (
WorkspaceSelectWidget,
Expand Down Expand Up @@ -94,7 +95,7 @@ def add(self, job: Job):
self.addItem(item)

scrollbar = self.verticalScrollBar()
if scrollbar.isVisible() and scrollbar.value() >= scrollbar.maximum() - 4:
if scrollbar and scrollbar.isVisible() and scrollbar.value() >= scrollbar.maximum() - 4:
self.scrollToBottom()

def update_selection(self):
Expand All @@ -118,7 +119,7 @@ def is_finished(self, job: Job):

def prune(self, jobs: JobQueue):
first_id = next((job.id for job in jobs if self.is_finished(job)), None)
while self.count() > 0 and self.item(0).data(Qt.ItemDataRole.UserRole) != first_id:
while self.count() > 0 and ensure(self.item(0)).data(Qt.ItemDataRole.UserRole) != first_id:
self.takeItem(0)

def rebuild(self):
Expand All @@ -131,8 +132,9 @@ def item_info(self, item: QListWidgetItem) -> tuple[str, int]: # job id, image

def handle_preview_click(self, item: QListWidgetItem):
if item.text() != "" and item.text() != "<no prompt>":
prompt = item.data(Qt.ItemDataRole.ToolTipRole)
QGuiApplication.clipboard().setText(prompt)
if clipboard := QGuiApplication.clipboard():
prompt = item.data(Qt.ItemDataRole.ToolTipRole)
clipboard.setText(prompt)

def mousePressEvent(self, e: QMouseEvent) -> None:
# make single click deselect current item (usually requires Ctrl+click)
Expand All @@ -151,7 +153,7 @@ def mousePressEvent(self, e: QMouseEvent) -> None:
return super().mousePressEvent(e)

def _find(self, id: JobQueue.Item):
items = (self.item(i) for i in range(self.count()))
items = (ensure(self.item(i)) for i in range(self.count()))
return next((item for item in items if self._item_data(item) == id), None)

def _item_data(self, item: QListWidgetItem):
Expand Down
4 changes: 3 additions & 1 deletion ai_diffusion/ui/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ def __init__(self):

self.preview_area = QLabel(self)
self.preview_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.preview_area.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
self.preview_area.setAlignment(
Qt.AlignmentFlag(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
)
layout.addWidget(self.preview_area)

@property
Expand Down
7 changes: 3 additions & 4 deletions ai_diffusion/ui/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,10 +1055,9 @@ def __init__(self, server: Server):

self.setWindowTitle("Configure Image Diffusion")
self.setMinimumSize(QSize(840, 480))
screen_size = QGuiApplication.primaryScreen().availableSize()
self.resize(
QSize(max(900, int(screen_size.width() * 0.6)), int(screen_size.height() * 0.8))
)
if screen := QGuiApplication.primaryScreen():
size = screen.availableSize()
self.resize(QSize(max(900, int(size.width() * 0.6)), int(size.height() * 0.8)))

layout = QHBoxLayout()
self.setLayout(layout)
Expand Down
4 changes: 3 additions & 1 deletion ai_diffusion/ui/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ def sd_version_icon(version: SDVersion, client: Client | None = None):
return icon("sd-version-15")
elif version is SDVersion.sdxl:
return icon("sd-version-xl")
return None
else:
util.client_logger.warning(f"Unresolved SD version {version}, cannot fetch icon")
return icon("warning")


def logo():
Expand Down
8 changes: 4 additions & 4 deletions ai_diffusion/ui/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,17 @@
import krita

from ..style import Style, Styles
from ..image import Bounds
from ..resources import ControlMode
from ..root import root
from ..client import filter_supported_styles, resolve_sd_version
from ..properties import Binding, bind, bind_combo
from ..jobs import Job, JobKind, JobState, JobQueue
from ..jobs import JobState, JobQueue
from ..model import Model, Workspace, ControlLayer
from ..attention_edit import edit_attention, select_on_cursor_pos
from ..util import ensure
from .settings import SettingsDialog
from .theme import SignalBlocker
from . import actions, theme
from ..attention_edit import edit_attention, select_on_cursor_pos


class QueueWidget(QToolButton):
Expand Down Expand Up @@ -400,7 +400,7 @@ def line_count(self):
@line_count.setter
def line_count(self, value: int):
self._line_count = value
fm = QFontMetrics(self.document().defaultFont())
fm = QFontMetrics(ensure(self.document()).defaultFont())
self.setFixedHeight(fm.lineSpacing() * value + 6)

def hasSelectedText(self) -> bool:
Expand Down
10 changes: 5 additions & 5 deletions ai_diffusion/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ def generate(
sampler_params = _sampler_params(style, live=live)
batch = 1 if live.is_active else batch

w = ComfyWorkflow(comfy.nodes_required_inputs)
w = ComfyWorkflow(comfy.nodes_inputs)
model, clip, vae = load_model_with_lora(w, comfy, style, is_live=live.is_active)
latent = w.empty_latent_image(extent.initial.width, extent.initial.height, batch)
model, positive, negative = apply_conditioning(cond, w, comfy, model, clip, style)
Expand All @@ -443,7 +443,7 @@ def inpaint(comfy: Client, style: Style, image: Image, mask: Mask, cond: Conditi
region_expanded = target_bounds.extent.at_least(64).multiple_of(8)
expanded_bounds = Bounds(*mask.bounds.offset, *region_expanded)

w = ComfyWorkflow(comfy.nodes_required_inputs)
w = ComfyWorkflow(comfy.nodes_inputs)
model, clip, vae = load_model_with_lora(w, comfy, style)
in_image = w.load_image(scaled_image)
in_mask = w.load_mask(scaled_mask)
Expand Down Expand Up @@ -521,7 +521,7 @@ def refine(
extent, image, batch = prepare_image(image, resolve_sd_version(style, comfy), downscale=False)
sampler_params = _sampler_params(style, live=live, strength=strength)

w = ComfyWorkflow(comfy.nodes_required_inputs)
w = ComfyWorkflow(comfy.nodes_inputs)
model, clip, vae = load_model_with_lora(w, comfy, style, is_live=live.is_active)
in_image = w.load_image(image)
if extent.is_incompatible:
Expand Down Expand Up @@ -554,7 +554,7 @@ def refine_region(
extent, image, mask_image, batch = prepare_masked(image, mask, sd_ver, downscale_if_needed)
sampler_params = _sampler_params(style, strength=strength, live=live)

w = ComfyWorkflow(comfy.nodes_required_inputs)
w = ComfyWorkflow(comfy.nodes_inputs)
model, clip, vae = load_model_with_lora(w, comfy, style, is_live=live.is_active)
in_image = w.load_image(image)
in_mask = w.load_mask(mask_image)
Expand Down Expand Up @@ -647,7 +647,7 @@ def upscale_tiled(
else: # SDXL
tile_extent = Extent(1024, 1024)

w = ComfyWorkflow(comfy.nodes_required_inputs)
w = ComfyWorkflow(comfy.nodes_inputs)
img = w.load_image(image)
checkpoint, clip, vae = load_model_with_lora(w, comfy, style)
upscale_model = w.load_upscale_model(model)
Expand Down
23 changes: 22 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
[tool.black]
line-length=100
preview=1
preview=1
include='(ai_diffusion|scripts|tests)/.*\.pyi?$'
extend-exclude='websockets|krita\.pyi$'

[tool.pyright]
include = [
"ai_diffusion",
"scripts/*.py",
"tests"
]
exclude = [
"**/__pycache__",
"**/.pytest_cache",
"**/.server",
"ai_diffusion/websockets",
]
ignore = [
"ai_diffusion/websockets",
"krita.pyi"
]
extraPaths = ["scripts/typeshed"]
reportMissingModuleSource = false
5 changes: 5 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# This file is for development and running tests.
# The plugin itself will run inside Krita's embedded Python, and only has access to the Python standard library and Qt5.

# Development
black

# Testing
aiohttp
markdown
numpy
Expand Down
4 changes: 2 additions & 2 deletions scripts/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ RUN git clone https://github.com/ltdrdata/ComfyUI-Manager.git /ComfyUI/custom_no

# Copy models
RUN mkdir -p models
COPY models/ /models/
COPY custom_nodes/ComfyUI_IPAdapter_plus/models/* /ComfyUI/custom_nodes/ComfyUI_IPAdapter_plus/models/
COPY downloads/models/ /models/
COPY downloads/custom_nodes/ComfyUI_IPAdapter_plus/models/* /ComfyUI/custom_nodes/ComfyUI_IPAdapter_plus/models/
COPY extra_model_paths.yaml /ComfyUI/

# Install Jupyter
Expand Down
Loading

0 comments on commit cf127d8

Please sign in to comment.