Compare commits

...
Author SHA1 Message Date
Mikaël Capelle 4fa50f89a4 Fix missing optional annotation in some cases. 2023-09-23 13:21:45 +02:00
Mikaël Capelle 962a3c2c1f Minor cleaning for Python 3.11. 2023-09-23 12:23:18 +02:00
Mikaël Capelle 7c2581eb0c Merge pull request #3 from ModOrganizer2/dev/pypi-trusted-publisher
Switch to PyPi trusted publisher.
2023-09-20 19:41:03 +02:00
Mikaël Capelle 336d80a5c6 Try to switch to PyPi trusted publisher. 2023-09-20 19:38:47 +02:00
Mikaël Capelle a1f5ef3a45 Merge pull request #2 from ModOrganizer2/dev/fix-deploy
Fix deployment of documentation.
2023-09-20 19:07:42 +02:00
Mikaël Capelle be6a6761f7 Fix deployment of documentation. 2023-09-20 19:07:28 +02:00
Mikaël Capelle 52f835dc75 Merge pull request #1 from ModOrganizer2/qt6
Qt6, PyBind11, new linters...
2023-09-19 22:28:39 +02:00
Mikaël Capelle 9ee5222bbc Fix documentation. 2023-09-19 22:27:05 +02:00
Mikaël Capelle 4655e1f3eb Fix typing error on CI. 2023-09-19 21:41:11 +02:00
Mikaël Capelle c4a6e2266d Many fixes and update for MO2 2.5. 2023-09-19 21:30:57 +02:00
Mikaël Capelle 966a84a59a PathLike -> PathLike[str] 2022-05-09 18:58:41 +02:00
Mikaël Capelle 3a5c16af21 Update for latest plugin_python. 2022-05-07 13:59:37 +02:00
Mikaël Capelle 3eb1daa053 Fix for mobase update (types in mobase). 2022-05-06 23:47:50 +02:00
Mikaël Capelle 0eca0f7644 Update documentation for widgets. 2022-05-05 19:13:12 +02:00
Mikaël Capelle 7467a2b11c Debug github action. 2022-05-05 18:27:05 +02:00
Mikaël Capelle eb420fcd79 Fix package generation. 2022-05-05 18:07:43 +02:00
Mikaël Capelle 156b827389 Add mobase.widgets stubs. 2022-05-05 17:52:32 +02:00
Mikaël Capelle 104e8612d8 Update README. 2022-05-05 14:36:45 +02:00
Mikaël Capelle 4f24d363ec Re-organizer code into a proper package. 2022-05-05 13:37:42 +02:00
Mikaël Capelle 2e4fd7c1a8 Update after Iterable -> Sequence for QList. 2022-05-05 13:14:15 +02:00
30 changed files with 1241 additions and 857 deletions
+26 -24
View File
@@ -1,36 +1,38 @@
name: Build Documentation
on:
pull_request:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
persist-credentials: false
# Standard drop-in approach that should work for most people.
- uses: ammaraskar/sphinx-action@master
env:
PYTHONPATH: .
with:
pre-build-command: "apt-get update -y && apt-get install -y libgl1-mesa-glx && cp stubs/2.4.0/mobase.pyi docs/mobase.py"
docs-folder: "docs/"
- name: Install SSH Client 🔑
uses: webfactory/ssh-agent@v0.4.1
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@3.7.1
with:
SSH: true
REPOSITORY_NAME: ModOrganizer2/python-plugins-doc
BRANCH: master
FOLDER: docs/build/html
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.11
- uses: abatilo/actions-poetry@v2
- name: Install
run: |
poetry install
- name: Install libgl1
run: sudo apt install -y libgl1 libegl1 libglib2.0-0 libxkbcommon0 libdbus-1-3
- name: Copy stubs
run: cp stubs/2.5.0/mobase-stubs/__init__.pyi docs/mobase.py
- name: Build
run: poetry run sphinx-build -b html docs/source docs/build
env:
PYTHONPATH: docs
- if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
name: Deploy Documentation
uses: JamesIves/github-pages-deploy-action@v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}
repository-name: ModOrganizer2/python-plugins-doc
branch: master
folder: docs/build
+16 -16
View File
@@ -5,20 +5,20 @@ on: [push, pull_request]
jobs:
checks:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox
- name: Test with tox
run: tox -e py38-lint
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.11
- uses: abatilo/actions-poetry@v2
- name: Install
run: |
poetry install
- name: Lint
run: |
poetry run black src --check --diff
poetry run isort -c src
poetry run mypy src
poetry run ruff src
poetry run pyright src
-18
View File
@@ -1,18 +0,0 @@
name: Check Documentation
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
# Standard drop-in approach that should work for most people.
- uses: ammaraskar/sphinx-action@master
env:
PYTHONPATH: .
with:
pre-build-command: "apt-get update -y && apt-get install -y libgl1-mesa-glx && cp stubs/2.4.0/mobase.pyi docs/mobase.py"
docs-folder: "docs/"
+46 -30
View File
@@ -1,41 +1,57 @@
# This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: Upload Python Package
name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
on:
push:
tags: ["*.dev[0-9]+"]
tags: ["*"]
jobs:
deploy:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Replace string
uses: frabert/replace-string-action@v1.1
id: version
with:
string: ${{ github.ref_name }}
pattern: "v?([0-9][.][0-9][.][0-9]).*"
replace-with: "$1"
- uses: actions/setup-python@v2
with:
python-version: 3.11
- uses: abatilo/actions-poetry@v2
- name: Build
run: |
cd stubs/setup
mkdir mobase-stubs
cp -r ../${{ steps.version.outputs.replaced }}/mobase-stubs/* mobase-stubs/
sed -i 's/__version__ = ".*"/__version__ = "${{ github.ref_name }}"/' mobase-stubs/__init__.pyi
TAG=${{ github.ref_name }}
poetry version ${TAG#v}
poetry build
- name: Store the distribution packages
uses: actions/upload-artifact@v3
with:
name: python-package-distributions
path: stubs/setup/dist/
publish-to-pypi:
name: Publish Python 🐍 distribution 📦 to PyPI
needs:
- build
runs-on: ubuntu-latest
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/checkout@v2
- name: Replace string
uses: frabert/replace-string-action@v1.1
id: version
with:
string: ${{ github.ref_name }}
pattern: "v?([0-9][.][0-9][.][0-9]).*"
replace-with: "$1"
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
cd stubs/setup
cp ../${{ steps.version.outputs.replaced }}/mobase.pyi mobase-stubs/__init__.pyi
sed -i 's/__version__ = ".*"/__version__ = "${{ github.ref_name }}"/' mobase-stubs/__init__.pyi
python setup.py sdist bdist_wheel
twine upload dist/*
- name: Download all the dists
uses: actions/download-artifact@v3
with:
name: python-package-distributions
path: dist/
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
+2 -1
View File
@@ -2,9 +2,10 @@
.mypy_cache
__pycache__
.vscode
**/*.egg-info
# The 'bin/' directory:
bin
docs/build
docs/mobase.py
docs/source/api
docs/source/api
+47 -49
View File
@@ -16,23 +16,12 @@ MO2.
You can install stubs for a specific version of MO2:
```bash
pip install mobase-stubs==2.3.2.*
```
If you want development stubs, you can install them this way:
```bash
# Clone this repository:
git clone https://github.com/ModOrganizer2/pystubs-generation.git
# Install the stubs:
cd pystubs-generation/stubs/setup
pip install .
pip install mobase-stubs==2.5.*
```
Some words of warning:
- The stubs are as correct as possible, but some errors are expected.
- If you see a `InterfaceNotImplemented` class anywhere in the stubs, it means that
a proper interface is currently not available.
- Some classes are said (in the stubs) to inherit `QWidget` or `QObject`. This is true
on the C++ side but NOT on the python side. The inheritance is only added to help with
auto-completion since these classes also override `__getattr__` to dispatch to the
@@ -44,35 +33,39 @@ Some words of warning:
The stubs are generated using python by parsing the `mobase` module.
You need the version of python that matches your current MO2 installation: e.g., if you
have a `python38.dll` in your MO2 installation path, then you need **Python 3.8**.
have a `python310.dll` in your MO2 installation path, then you need **Python 3.10**.
To generate the stubs, you can run:
```
# Change the output folder to whatever you want:
python main.py -c configs\config-2.4.yml ${MO2_INSTALL_PATH}
```bash
# install the package
poetry install
# change the output folder to whatever you want
mo2-stubs-generator -c configs/config-2.4.yml -o mobase-stubs ${MO2_INSTALL_PATH}
```
Where `${MO2_INSTALL_PATH}` is the path to your MO2 installation (the one containing `ModOrganizer.exe`).
Where `${MO2_INSTALL_PATH}` is the path to your MO2 installation (the one
containing `ModOrganizer.exe`).
The stubs are generated under `stubs/setup/mobase-stubs/__init__.pyi`, you
can change the output file by using the `-o` option
The latest stubs are kept under `stubs/setup/mobase-stubs/__init__.pyi`,
and when a new version is released, the stubs are backed-up under
`stubs/x.y.z/mobase.pyi`.
The stubs are generated under `stubs/setup/mobase-stubs` by default, you
can change the output file by using the `-o` option.
The stubs under `stubs/setup/mobase-stubs` should not be committed as these are
generated from the version stubs under `stubs/${VERSION}/mobase-stubs`.
A few options are available for `main.py`:
A few options are available for `mo2-stubs-generator`:
```
usage: Stubs generator for the MO2 python interface [-h] [-o OUTPUT] [-v] [-c CONFIG] INSTALL_DIR
```bash
$ mo2-stubs-generator --help
usage: stubs generator for the MO2 python interface [-h] [-o OUTPUT] [-v] [-c CONFIG] INSTALL_DIR
positional arguments:
INSTALL_DIR installation directory of Mod Organizer 2
optional arguments:
options:
-h, --help show this help message and exit
-o OUTPUT, --output OUTPUT
output file (default stubs/setup/mobase-stubs/__init__.pyi)
output folder (default stubs/setup/mobase-stubs)
-v, --verbose verbose mode (all logs go to stderr)
-c CONFIG, --config CONFIG
configuration file
@@ -81,19 +74,7 @@ optional arguments:
The stubs generator will try hard to find a valid stubs for all classes
and methods of `mobase`.
A lot of information is available through the `-v` options. Without it,
only conversions or fixes
considered "strange" will be shown.
For instance, here is the output with the current `config-2.4.yml` file:
```
WARNING: Replacing IOrganizer::FileInfo with FileInfo.
WARNING: Replacing IOrganizer::FileInfo with FileInfo.
WARNING: Replacing IPluginInstaller::EInstallResult with InstallResult.
WARNING: Replacing IPluginInstaller::EInstallResult with InstallResult.
```
As you can see, only a few types were manually fixed (specified in
`config-2.4.yml`).
only conversions or fixes considered "strange" will be shown.
## Configuration file
@@ -102,20 +83,37 @@ deduced by `main` (or are too complex to deduce), and the documentation for ever
## Uploading the stubs to pypi
The upload of the stubs to https://pypi.org/project/mobase-stubs/ should be
done automatically when a new Github release is made.
The upload of the stubs to [https://pypi.org/project/mobase-stubs/](https://pypi.org/project/mobase-stubs/)
should be done automatically when a new Github tag is pushed.
## Extras — Starts a python interpreter with `mobase`
## Extras — Using `mobase` in a Python interpreter
It is possible to start a (i)python interpret with `mobase` imported by running:
It is possible to start a (i)python interpreter with `mobase` imported by running
```
python -im generator.loader ${MO2_INSTALL_PATH}
```bash
python -i -m mo2.stubs.generator.loader ${MO2_INSTALL_PATH}
```
This has no real usage except for MO2 developers since most classes from the `mobase` module cannot be instantiated.
You can also import `mobase` in your code using the following (after installing
this package):
# License
```python
from mo2.stubs.generator import load_mobase
mobase = load_mobase(MO2_INSTALL_PATH)
# the above will probably not give you type-completion in your IDE or typing, so
# you can use the following (if the stubs are installed)
load_mobase(MO2_INSTALL_PATH)
import mobase
import mobase.widgets
```
**Note:** Most classes in `mobase` cannot be instantiated, so this is mostly intended
for MO2 developers.
## License
The MIT License (MIT)
+146 -26
View File
@@ -1,24 +1,14 @@
---
version: 1
# version of the configuration
version: 2
# This is the list of type to replace:
replacements:
Organizer::FileInfo: FileInfo
IOrganizer::FileInfo: FileInfo
IPluginInstaller::EInstallResult: InstallResult
GuessedValue< QString>: GuessedString
# List of names to ignores:
ignores:
- toPyQt
# version of the stubs - this is overridden when publishing
__version__: "2.5.0"
# This is the root of the mobase module and will contain everything
# related to functions / classes, including their documentation.
mobase:
# Version of the stubs - this is overridden by when publishing.
__version__: "2.5.0"
getFileVersion:
__doc__: Retrieve the file version of the given executable.
args:
@@ -265,7 +255,7 @@ mobase:
type: str
desc: Full path to the file.
origins:
type: List[str]
type: list[str]
desc: |
List of origins containing providing this file. The first origin in the list
is the highest priority one (actually providing the file).
@@ -329,7 +319,7 @@ mobase:
__doc__: |
The parent tree containing this entry, or a `None` if this entry is the root
or the parent tree is unreachable.
type: Optional[IFileTree]
type: IFileTree | None
path:
__doc__: |
@@ -366,6 +356,8 @@ mobase:
returns:
lightPluginsAreSupported:
returns: True if light plugins are supported, False otherwise.
overridePluginsAreSupported:
returns: True if override plugins are supported, False otherwise.
readPluginLists:
__doc__:
args:
@@ -673,7 +665,7 @@ mobase:
__doc__: |
The entry at the given location, or `None` if the entry was not found or
was not of the correct type.
type: Optional[Union[IFileTree, FileTreeEntry]]
type: IFileTree | FileTreeEntry | None
insert:
__doc__: |
@@ -1236,7 +1228,7 @@ mobase:
type: MoVariant
desc: The data that was included in the request.
result_data:
type: Dict[str, MoVariant]
type: dict[str, MoVariant]
desc: The data included in the response.
filesAvailable:
@@ -1252,7 +1244,7 @@ mobase:
type: MoVariant
desc: The data that was included in the request.
result_data:
type: List[ModRepositoryFileInfo]
type: list[ModRepositoryFileInfo]
desc: List of file information objects.
fileInfoAvailable:
@@ -1279,7 +1271,7 @@ mobase:
type: MoVariant
desc: The data that was included in the request.
result_data:
type: Dict[str, MoVariant]
type: dict[str, MoVariant]
desc: The data included in the response.
downloadURLsAvailable:
@@ -1301,7 +1293,7 @@ mobase:
type: MoVariant
desc: The data that was included in the request.
result_data:
type: Dict[str, MoVariant]
type: dict[str, MoVariant]
desc: The data included in the response.
endorsementsAvailable:
@@ -2000,7 +1992,7 @@ mobase:
abstract: false
returns:
__doc__: A mapping from feature type to actual game features.
type: Dict[Type[GameFeatureType], GameFeatureType]
type: dict[Type[GameFeatureType], GameFeatureType]
gameDirectory:
returns: The directory containing the game installation.
@@ -2102,6 +2094,14 @@ mobase:
savesDirectory:
returns: The directory where save games are stored.
secondaryDataDirectories:
__doc__: |
Retrieve the list of secondary data directories. Each directories should be
assigned a unique name that differs from "data" which is the name of the main
data directory returned by dataDirectory().
returns: A mapping from unique name to secondary data directories.
setGamePath:
__doc__: |
Set the path to the managed game.
@@ -2798,7 +2798,7 @@ mobase:
filetree: The tree to try to fix. Can be modified during the process.
returns:
__doc__: The fixed tree, or a null pointer if the tree could not be fixed.
type: Optional["IFileTree"]
type: IFileTree | None
ModDataContent:
__doc__: |
@@ -3008,17 +3008,17 @@ mobase:
parent: The parent widget.
returns:
__doc__: A SaveGameInfoWidget to display information about save game.
type: Optional[ISaveGameInfoWidget]
type: ISaveGameInfoWidget | None
ScriptExtender:
__doc__:
__abstract__: true
BinaryName:
binaryName:
__doc__:
returns: The name of the script extender binary.
PluginPath:
pluginPath:
__doc__:
returns: The script extender plugin path, relative to the data folder.
@@ -3147,3 +3147,123 @@ mobase:
scheme:
returns: The version scheme in effect for this VersionInfo.
mobase.widgets:
TaskDialog:
__doc__: Customizable choice dialog.
__init__:
__doc__: Construct a new TaskDialog.
args:
parent: Parent widget of the dialog.
title: Title of the dialog.
main: Header of the dialog (big text at the top).
content: Main message of the dialog (text below main).
details: Details for the dialog, initially collapsed (bottom of the dialog).
icon: Icon for the dialog.
buttons: List of buttons for the dialog.
remember: Remember the choice for this dialog.
addButton:
__doc__: Add a custom button to this TaskDialog.
args:
button: Button to add to the dialog.
addContent:
__doc__: |
Add a custom widget content to this TaskDialog. Widget content are put between
content and buttons (above buttons).
args:
widget: Widget to add.
exec:
__doc__: |
Display this dialog and wait for user-interaction to return. This is a blocking
function.
returns: |
The button clicked by the user. Without custom buttons, this return Ok,
otherwise it returns the button set in the TaskDialogButton.
setContent:
__doc__: Set the top-level message of this dialog.
args:
content: Top-level message to set.
setDetails:
__doc__: |
Set the details for this TaskDialog.
The details are hidden by default and the user can display them by clicking
the "Details" button at the bottom of the TaskDialog.
args:
details: Details content to display. Can be a multi-line string.
setIcon:
__doc__: Set the icon of the dialog.
args:
icon: Icon of the dialog.
setMain:
__doc__: |
Set the main message of the dialog. The main message is displayed at the top of
the dialog in large font.
args:
main: Main message of the dialog.
setRemember:
__doc__: Configure the dialog to remember user-choice.
args:
action:
file:
setTitle:
__doc__: Set the title of the dialog.
args:
title: Title of the dialog.
setWidth:
__doc__: Set the width of the dialog.
args:
width: Width of the dialog.
TaskDialogButton:
__doc__: Special button to be used inside TaskDialog widgets.
__init__.1:
__doc__: Create a TaskDialogButton.
args:
text: Label of the button.
description: Description of the button.
button: Value returned by TaskDialog.exec() if this button is clicked.
__init__.2:
__doc__: Create a TaskDialogButton without description.
args:
text: Label of the button.
button: Value returned by TaskDialog.exec() if this button is clicked.
properties[]:
text:
type: str
desc: Label of the button.
description:
type: str
desc: Description of the button.
button:
type: PyQt6.QtWidgets.QMessageBox.StandardButton
desc: Value returned by TaskDialog.exec() if this button is clicked.
-20
View File
@@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-35
View File
@@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd
+1 -1
View File
@@ -1,4 +1,4 @@
sphinx-rtd-theme
sphinx-autodoc-typehints
sphinx-automodapi
PyQt5
PyQt6
+2 -10
View File
@@ -10,21 +10,13 @@
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath("../../stubs/2.4.0/"))
# -- Project information -----------------------------------------------------
project = "MO2 Python Plugin API"
copyright = "2020, Holt59"
copyright = "2023, Holt59"
author = "Holt59"
# The full version, including alpha/beta/rc tags
release = "2.3rc1"
# -- General configuration ---------------------------------------------------
@@ -69,5 +61,5 @@ html_favicon = "mo2.ico"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
# html_static_path = ["_static"]
html_extra_path = [".nojekyll"]
-5
View File
@@ -1,5 +0,0 @@
# -*- encoding: utf-8 -*-
import logging
LOGGER = logging.getLogger(__name__)
-190
View File
@@ -1,190 +0,0 @@
# -*- encoding: utf-8 -*-
import argparse
import logging
from pathlib import Path
from typing import cast
import black
import isort
from generator import LOGGER
from generator.loader import load_mobase
from generator.mtypes import Class, Function, PyType
from generator.parser import is_enum
from generator.register import MOBASE_REGISTER
from generator.utils import Settings, clean_class
from generator.writer import Writer
parser = argparse.ArgumentParser("Stubs generator for the MO2 python interface")
parser.add_argument(
"install_dir",
metavar="INSTALL_DIR",
type=Path,
default=None,
help="installation directory of Mod Organizer 2",
)
parser.add_argument(
"-o",
"--output",
type=Path,
default="stubs/setup/mobase-stubs/__init__.pyi",
help="output file (default stubs/setup/mobase-stubs/__init__.pyi)",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="verbose mode (all logs go to stderr)"
)
parser.add_argument(
"-c",
"--config",
type=argparse.FileType("r"),
default=None,
help="configuration file",
)
args = parser.parse_args()
logging.basicConfig()
LOGGER.setLevel(logging.WARNING)
if args.verbose:
LOGGER.setLevel(logging.INFO)
output_path = cast(Path, args.output)
# Load settings from the configuration:
settings: Settings = Settings(register=MOBASE_REGISTER)
if args.config is not None:
settings = Settings(MOBASE_REGISTER, args.config)
# Parse mobase:
# Load mobase (cannot simply do "import mobase"):
mobase = load_mobase(Path(args.install_dir))
# List of objects:
objects = []
for name in dir(mobase):
if name.startswith("__"):
continue
if name in settings.ignore_names:
continue
# we do not want the real MoVariant
if name == "MoVariant":
continue
# ignore the private module
if name == "private":
continue
# for now, ignore this since it is a submodule and we
# not handle them
if name == "widgets":
continue
# IPlugin is not the real object
if name == "IPlugin":
continue
objects.append((name, getattr(mobase, name)))
# enum first, and then alphabetical, should be fine with the __future__ import
objects = sorted(
objects, key=lambda e: (isinstance(e[1], type), not is_enum(e[1]), e[0])
)
for n, o in objects:
MOBASE_REGISTER.add_object(n, o)
# Process everything:
for n, o in objects:
# Create the corresponding object:
c = MOBASE_REGISTER.make_object(n, o)
if isinstance(c, Class):
# Clean the class (e.g., remove duplicates methods due to wrappers):
clean_class(c, settings)
# Path the class using the configuration:
settings.patch_class(c)
elif isinstance(c, list) and isinstance(c[0], Function):
settings.patch_functions(c)
else:
LOGGER.critical(
"Cannot generated stubs for {}, unsupported object type.".format(n)
)
# Write everything:
with open(args.output, "w") as output:
writer = Writer(output, settings)
# the __future__ import must be at the beginning
writer.print_imports([("__future__", ["annotations"])])
writer.print_version(settings.mobase["__version__"]) # type: ignore
writer.print_imports(
[
"abc",
("enum", ["Enum"]),
("pathlib", ["Path"]),
(
"typing",
[
"Dict",
"Iterable",
"Iterator",
"List",
"Tuple",
"Union",
"Any",
"Optional",
"Callable",
"overload",
"Set",
"TypeVar",
"Type",
],
),
"PyQt6.QtCore",
"PyQt6.QtGui",
"PyQt6.QtWidgets",
]
)
# Needs to define the MVariant and GameFeatureType type:
writer._print(f"MoVariant = {PyType.MO_VARIANT}")
writer._print(f"FileWrapper = {PyType.FILE_WRAPPER}")
writer._print(f"DirectoryWrapper = {PyType.DIRECTORY_WRAPPER}")
writer._print('GameFeatureType = TypeVar("GameFeatureType")')
writer._print()
# This is a class to represent interface not implemented:
writer.print_class(Class("InterfaceNotImplemented", [], []))
writer._print()
for n, o in objects:
# Get the corresponding object:
c = MOBASE_REGISTER.get_object(n)
if isinstance(c, Class):
writer.print_class(c)
elif isinstance(c, list) and isinstance(c[0], Function):
for fn in c:
writer.print_function(fn)
black.format_file_in_place(
output_path,
fast=False,
mode=black.Mode(is_pyi=args.output.name.endswith("pyi")),
write_back=black.WriteBack.YES,
)
isort.api.sort_file(output_path)
+61
View File
@@ -0,0 +1,61 @@
[tool.poetry]
name = "mo2-pystubs-generation"
version = "0.1.0"
description = ""
authors = ["Holt59 <capelle.mikael@gmail.com>"]
license = "MIT"
readme = "README.md"
packages = [{ include = "mo2", from = "src" }]
[tool.poetry.scripts]
mo2-stubs-generator = "mo2.stubs.generator.__main__:main"
[tool.poetry.dependencies]
python = "^3.11"
pyqt6 = "^6.5.2"
pyyaml = "^6.0.1"
[tool.poetry.group.dev.dependencies]
black = "^23.9.1"
mypy = "^1.5.1"
pyright = "^1.1.327"
isort = "^5.12.0"
ruff = "^0.0.290"
flake8 = "^6.1.0"
flake8-black = "^0.3.6"
flake8-pyproject = "^1.2.3"
types-pyyaml = "^6.0.12.11"
[tool.poetry.group.doc.dependencies]
sphinx-rtd-theme = "^1.3.0"
sphinx-autodoc-typehints = "^1.24.0"
sphinx-automodapi = "^0.16.0"
sphinx = "^7.2.6"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.flake8]
max-line-length = 88
extend-ignore = ["E203"]
[tool.isort]
profile = "black"
multi_line_output = 3
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.mypy]
warn_return_any = true
warn_unused_configs = true
namespace_packages = true
[tool.pyright]
# reportMissingTypeStubs = true
# reportUntypedBaseClass = false
typeCheckingMode = "strict"
-40
View File
@@ -1,40 +0,0 @@
[flake8]
# Use black line length:
max-line-length = 88
extend-ignore =
# See https://github.com/PyCQA/pycodestyle/issues/373
E203,
per-file-ignores =
*.pyi: E301, E302, E305, E501, E701, E741, F401, F403, F405, F822
# Since typing.pyi defines "overload" this is not recognized by flake8 as typing.overload.
# Unfortunately, flake8 does not allow to "noqa" just a specific error inside the file itself.
typing.pyi: E301, E302, E305, E501, E701, E741, F401, F403, F405, F811, F822
[mypy]
warn_return_any = True
warn_unused_configs = True
namespace_packages = True
[isort]
profile = black
multi_line_output = 3
[tox:tox]
skipsdist = true
envlist = py310-lint
[testenv:py310-lint]
skip_install = true
deps =
black
mypy
flake8
flake8-black
git+https://github.com/TilmanK/PyQt6-stubs.git
types-PyYAML
isort
commands =
black generator main.py --check --diff
flake8 generator main.py
mypy generator main.py
isort -c generator main.py
+7
View File
@@ -0,0 +1,7 @@
import logging
from .loader import load_mobase
LOGGER = logging.getLogger(__name__)
__all__ = ["load_mobase", "LOGGER"]
+229
View File
@@ -0,0 +1,229 @@
import argparse
import inspect
import logging
import types
from pathlib import Path
from typing import Callable
import black
import isort
from .loader import load_mobase
from .mtypes import Class, PyTyping
from .parser import is_enum
from .register import MobaseRegister
from .utils import Settings, clean_class
from .writer import Writer, is_list_of_functions
LOGGER = logging.getLogger(__package__)
def extract_objects(module: object, skips: list[str] = []) -> list[tuple[str, type]]:
objects: list[tuple[str, type]] = []
assert hasattr(module, "__name__")
module_name: str = module.__name__ # type: ignore
for name in dir(module):
if name.startswith("__") or name in skips:
continue
obj = getattr(module, name)
# skip submodules
if inspect.ismodule(obj):
continue
# skip imports - type object have wrong __module__?
if hasattr(obj, "__module__") and obj.__module__ != module_name:
if obj.__module__ != types.__name__ or hasattr(types, name):
continue
objects.append((name, obj))
return objects
def add_mobase_header(writer: Writer):
writer.print_imports(
[
"abc",
("enum", ["Enum"]),
"os",
(
"typing",
[
"Callable",
"Dict",
"Iterator",
"List",
"Optional",
"overload",
"Sequence",
"Set",
"Tuple",
"Type",
"TypeVar",
"Union",
],
),
"PyQt6.QtCore",
"PyQt6.QtGui",
"PyQt6.QtWidgets",
]
)
def add_mobase_widgets_header(writer: Writer):
writer.print_imports(
[
(
"typing",
["List", "Tuple", "Union", "overload"],
),
"PyQt6.QtCore",
"PyQt6.QtGui",
"PyQt6.QtWidgets",
]
)
def main() -> None:
parser = argparse.ArgumentParser("stubs generator for the MO2 python interface")
parser.add_argument(
"install_dir",
metavar="INSTALL_DIR",
type=Path,
default=None,
help="installation directory of Mod Organizer 2",
)
parser.add_argument(
"-o",
"--output",
type=Path,
default=Path("stubs/setup/mobase-stubs"),
help="output folder (default stubs/setup/mobase-stubs)",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="verbose mode (all logs go to stderr)",
)
parser.add_argument(
"-c",
"--config",
type=Path,
default=None,
help="configuration file",
)
args = parser.parse_args()
logging.basicConfig()
LOGGER.setLevel(logging.WARNING)
if args.verbose:
LOGGER.setLevel(logging.INFO)
output_path: Path = args.output
config_path: Path | None = args.config
# create the register
register = MobaseRegister()
# load mobase (cannot simply do "import mobase")
mobase = load_mobase(Path(args.install_dir))
# headers
module_headers: dict[str, Callable[[Writer], None]] = {
"mobase": add_mobase_header,
"mobase.widgets": add_mobase_widgets_header,
}
# list of objects directly in mobase
module_objects: dict[str, list[tuple[str, type]]] = {
"mobase": extract_objects(
mobase,
[
# the "real" IPlugin is IPluginBase
"IPlugin",
],
),
"mobase.widgets": extract_objects(getattr(mobase, "widgets")),
}
for name, objects in module_objects.items():
# load settings from the configuration
settings: Settings = Settings(register)
if config_path is not None:
with open(config_path, "r") as fp:
settings = Settings(register, fp, module=name)
for n, o in objects:
register.add_object(n, o)
# enum first, and then alphabetical, should be fine with the __future__ import
objects = sorted(
objects, key=lambda e: (isinstance(e[1], type), not is_enum(e[1]), e[0])
)
# Process everything:
for n, o in objects:
# Create the corresponding object:
c = register.make_object(n, o)
if isinstance(c, Class):
# Clean the class (e.g., remove duplicates methods due to wrappers):
clean_class(c)
# Path the class using the configuration:
settings.patch_class(c)
elif isinstance(c, PyTyping):
...
elif is_list_of_functions(c):
settings.patch_functions(c)
else:
LOGGER.critical(
"Cannot generated stubs for {}, unsupported object type.".format(n)
)
output_folder = output_path
if name != "mobase":
output_folder = output_path.joinpath(
name.replace("mobase.", "").replace(".", "/")
)
# create directory if required
output_folder.mkdir(parents=True, exist_ok=True)
# write everything
with open(output_folder.joinpath("__init__.pyi"), "w") as output:
writer = Writer(package=name, output=output, settings=settings)
# the __future__ import must be at the beginning
writer.print_imports([("__future__", ["annotations"])])
writer.print_version(settings.version)
module_headers[name](writer)
for n, o in objects:
# Get the corresponding object:
c = register.get_object(n)
writer.print_object(c)
black.format_file_in_place(
output_folder.joinpath("__init__.pyi"),
fast=False,
mode=black.Mode(is_pyi=True),
write_back=black.WriteBack.YES,
)
isort.api.sort_file(output_folder.joinpath("__init__.pyi"))
if __name__ == "__main__":
main()
@@ -1,11 +1,11 @@
# -*- encoding: utf-8 -*-
import os
import sys
from modulefinder import Module
from pathlib import Path
from typing import Any
def load_mobase(path: Path):
def load_mobase(path: os.PathLike[Any]) -> Module:
"""
Load the mobase from the given MO2 installation path and
returns it.
@@ -16,6 +16,8 @@ def load_mobase(path: Path):
Returns: The mobase module.
"""
path = Path(path)
# We need absolute path for loading DLL and modules:
path = path.resolve()
@@ -27,19 +29,18 @@ def load_mobase(path: Path):
[str(path), str(path.joinpath("dlls")), os.environ.get("PATH", "")]
)
else:
os.add_dll_directory(str(path))
os.add_dll_directory(str(path.joinpath("dlls")))
os.add_dll_directory(str(path)) # type: ignore
os.add_dll_directory(str(path.joinpath("dlls"))) # type: ignore
# We need to add plugins/data to sys.path, mainly for PyQt6
sys.path.insert(1, path.joinpath("plugins", "plugin_python", "libs").as_posix())
import mobase
import mobase # type: ignore
return mobase
return mobase # type: ignore
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
@@ -1,8 +1,7 @@
# -*- encoding: utf-8 -*-
from __future__ import annotations
import re
from typing import Final, TypeVar
class PyType:
@@ -10,15 +9,6 @@ class PyType:
Class representing a python type.
"""
# The `MoVariant` actual type - This should be list["MoVariant"] and
# Dict[str, "MoVariant"], but mypy (and other type checkers) do not
# handle recursive definition yet:
MO_VARIANT = """Union[None, bool, int, str, list[Any], dict[str, Any]]"""
# File/Directory wrappers
FILE_WRAPPER = """Union[str, PyQt6.QtCore.QFileInfo, Path]"""
DIRECTORY_WRAPPER = """Union[str, PyQt6.QtCore.QDir, Path]"""
name: str
def __init__(self, name: str | type):
@@ -30,16 +20,9 @@ class PyType:
self.name = name.strip()
# remove MOBase:: and mobase.
self.name = self.name.replace("mobase.", "")
# replace QFlags[xxx] with xxx
self.name = re.sub(r"QFlags\[([^]]*)\]", r"\1", self.name)
# we replace QVariant with MoVariant which is valid python type
if self.name == "QVariant":
self.name = "MoVariant"
# find PyQt types
for m in (QtCore, QtGui, QtWidgets):
if self.name in dir(m):
@@ -51,8 +34,12 @@ class PyType:
A valid typing representation for this type.
"""
# IPluginBase -> IPlugin
if self.name == "IPluginBase":
if self.name == "mobase.IPluginBase":
return "IPlugin"
# PathLike should be [] in the stubs
self.name = self.name.replace("os.PathLike", "os.PathLike[str]")
return self.name
def is_none(self) -> bool:
@@ -133,7 +120,6 @@ class Argument:
@property
def value(self) -> str | None:
value = self._value
if value is None:
@@ -142,10 +128,18 @@ class Argument:
# pybind11 puts enum in <> so we need to fix
m = re.match(r"<([^:]+):\s*[0-9]+>", value)
if m:
# we also need to use the upper case version
value = m.group(1)
parts = value.split(".")
value = ".".join(parts[:-1] + [parts[-1].upper()])
# if this is a mobase enum, we eed to use the upper case version
if self.type.name.startswith("mobase"):
value = m.group(1)
parts = value.split(".")
value = ".".join(parts[:-1] + [parts[-1].upper()])
# PyQt -> need to fix
elif self.type.name.startswith("PyQt"):
parts = m.group(1).split(".")
value = f"{self.type.name}.{parts[-1]}"
else:
value = m.group(1)
return value
@@ -317,6 +311,7 @@ class Class:
def __init__(
self,
package: str,
name: str,
bases: list[Class],
methods: list[Method],
@@ -325,7 +320,7 @@ class Class:
inner_classes: list[Class] = [],
doc: str = "",
):
self.package = package
self.name = name
self.bases = bases
self.methods = methods
@@ -343,7 +338,7 @@ class Class:
for ic in self.inner_classes:
ic.outer_class = self
def is_abstract(self):
def is_abstract(self) -> bool:
"""
Returns:
True if this class is abstract, False otherwise.
@@ -351,7 +346,7 @@ class Class:
return self.abstract or any(bc.is_abstract() for bc in self.bases)
@property
def canonical_name(self):
def canonical_name(self) -> str:
"""
Returns:
The canonical name of this class.
@@ -359,11 +354,21 @@ class Class:
name = self.name
oc = self.outer_class
while oc is not None:
name = "{}.{}".format(oc.name, name)
name = f"{oc.name}.{name}"
oc = oc.outer_class
return name
@property
def full_name(self) -> str:
"""
Returns:
The full name of this class, i.e., package.canonical_name.
"""
if self.package:
return f"{self.package}.{self.canonical_name}"
return self.canonical_name
@property
def all_bases(self) -> set[Class]:
"""
@@ -378,9 +383,6 @@ class Class:
def is_deprecated(self):
return self.deprecated
def __str__(self):
return self.canonical_name
class PyClass(Class):
@@ -391,9 +393,10 @@ class PyClass(Class):
def __init__(
self,
package: str,
name: str,
):
super().__init__(name, [], [])
super().__init__(package, name, [], [])
self.abstract = False
@@ -403,12 +406,15 @@ class Enum(Class):
Class representing an enum.
"""
def __init__(self, name: str, values: dict[str, int], methods: list[Method]):
def __init__(
self, package: str, name: str, values: dict[str, int], methods: list[Method]
):
# Note: Boost.Python.enum inherits int() not enum.Enum() but for the sake
# of stubs, I think making them inherit enum.Enum is more appropriate:
super().__init__(
package,
name,
[PyClass("Enum")],
[PyClass("", "Enum")],
methods,
inner_classes=[],
constants=[Constant(k, None, v) for k, v in values.items()],
@@ -416,3 +422,26 @@ class Enum(Class):
def is_abstract(self):
return False
class PyTyping:
"""
Class representing a typing object, e.g., MoVariant.
"""
name: Final[str]
typing: Final[str]
def __init__(self, name: str, obj: object):
self.name = name
_typing: str
if obj.__module__ == "types":
_typing = str(obj)
# type-var have a weird name, e.g., ~Name
elif type(obj) is TypeVar:
_typing = f'TypeVar("{name}")'
else:
_typing = str(obj)
self.typing = _typing
@@ -1,13 +1,11 @@
# -*- encoding: utf-8 -*-
import inspect
import logging
import re
import types
from collections import OrderedDict, defaultdict
from itertools import chain
from typing import Iterable, cast
from typing import Any, Iterable, cast
from . import LOGGER
from .mtypes import (
Argument,
Class,
@@ -22,8 +20,12 @@ from .mtypes import (
)
from .register import MobaseRegister
LOGGER = logging.getLogger(__package__)
def magic_split(value: str, sep=",", open="(<[", close=")>]"):
def magic_split(
value: str, sep: str = ",", open: str = "(<[", close: str = ")>]"
) -> list[str]:
"""
Split the value according to the given separator, but keeps together elements
within the given separator. Useful to split C++ signature function since type names
@@ -42,8 +44,8 @@ def magic_split(value: str, sep=",", open="(<[", close=")>]"):
Returns: The list of split parts from value.
"""
i, j = 0, 0
s: list[str] = []
r = []
s: list[int] = []
r: list[str] = []
while i < len(value):
j = i + 1
while j < len(value):
@@ -96,8 +98,8 @@ def parse_python_signature(s: str, name: str) -> tuple[PyType, list[Argument]]:
args = magic_split(m.group(1).strip(), ",", open="[", close="]")
return_type = m.group(2)
arguments = []
for i, pa in enumerate(args):
arguments: list[Argument] = []
for pa in args:
m = re.search(
r"(?P<name>[^:]+)\s*:\s*(?P<type>[^=]+)\s*(=\s*(?P<value>[^,]+))?",
pa.strip(),
@@ -106,9 +108,12 @@ def parse_python_signature(s: str, name: str) -> tuple[PyType, list[Argument]]:
raise ValueError(f"invalid argument: {pa}, {s}")
matches = m.groupdict()
arguments.append(
Argument(matches["name"], PyType(matches["type"]), matches["value"])
)
type_ = matches["type"]
if matches["value"] == "None":
if "None" not in type_ and "MoVariant" not in type_:
type_ = type_ + " | None"
arguments.append(Argument(matches["name"], PyType(type_), matches["value"]))
return PyType(return_type), arguments
@@ -140,7 +145,7 @@ class Overload:
self.arguments = arguments
def parse_pybind11_function_docstring(e) -> list[Overload]:
def parse_pybind11_function_docstring(e: type) -> list[Overload]:
"""
Parse the docstring of the given element.
@@ -150,7 +155,7 @@ def parse_pybind11_function_docstring(e) -> list[Overload]:
Returns:
A list of overloads for the given function.
"""
lines = e.__doc__.strip().split("\n")
lines = (e.__doc__ or "").strip().split("\n")
signatures: list[str]
if len(lines) == 1:
@@ -166,7 +171,6 @@ def parse_pybind11_function_docstring(e) -> list[Overload]:
# them...
overloads: list[Overload] = []
for signature in signatures:
# fix MOBase:: in some places to get proper Python types
signature = signature.replace("MOBase::", "mobase.").replace("::", ".")
@@ -179,7 +183,7 @@ def parse_pybind11_function_docstring(e) -> list[Overload]:
return overloads
def make_functions(name: str, e) -> list[Function]:
def make_functions(e: type) -> list[Function]:
overloads = parse_pybind11_function_docstring(e)
return [
@@ -193,13 +197,11 @@ def make_functions(name: str, e) -> list[Function]:
]
def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
def make_class(e: type, register: MobaseRegister) -> Class:
"""
Constructs a Class object from the given python class.
Args:
fullname: Name of the class (might be different from __name__ for inner
classes).
e: The python class (created from boost) to construct an object for.
class_register:
@@ -257,7 +259,7 @@ def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
inner_classes = [ic[1] for ic in all_attrs if isinstance(ic[1], type)]
pinner_classes: list[Class] = [
cast(Class, register.make_object(f"{fullname}.{ic.__name__}", ic))
cast(Class, register.make_object(f"{e.__qualname__}.{ic.__name__}", ic))
for ic in inner_classes
]
@@ -285,7 +287,7 @@ def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
Overload(
return_type=PyType("bool"),
arguments=[
Argument("self", PyType(fullname)),
Argument("self", PyType(e.__module__ + "." + e.__qualname__)),
Argument("other", PyType("object")),
],
)
@@ -312,7 +314,9 @@ def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
for base_class in base_classes:
for biclass in base_class.inner_classes:
if isinstance(biclass, Enum) and biclass.name == base_name:
arg._value = base_class.name + "." + value
arg._value = ( # pyright: ignore[reportPrivateUsage]
base_class.name + "." + value
)
methods.append(
Method(
@@ -325,8 +329,8 @@ def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
)
# Retrieve the attributes:
constants = []
properties = []
constants: list[Constant] = []
properties: list[Property] = []
for name, attr in all_attrs:
if callable(attr) or isinstance(attr, type):
continue
@@ -343,7 +347,9 @@ def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
direct_bases: list[Class] = []
for c in e.__bases__:
if c.__module__ != "pybind11_builtins":
direct_bases.append(register.get_object(c.__name__))
b = register.get_object(c.__name__)
assert isinstance(b, Class)
direct_bases.append(b)
# Forcing QWidget base for XWidget classes since these do not show up
# and we use a trick:
@@ -353,23 +359,25 @@ def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
"PyQt6.QtWidgets.QWidget", e.__name__
)
)
direct_bases.append(PyClass("PyQt6.QtWidgets.QWidget"))
direct_bases.append(PyClass("PyQt6.QtWidgets", "QWidget"))
# check if it an enum
if is_enum(e):
# all pybind11 enums have a .__entries attribute
values = e.__entries # type: ignore
values = cast(dict[str, tuple[int, Any]], e.__entries) # type: ignore
# drop the __init__
methods = [m for m in methods if m.name != "__init__"]
return Enum(
e.__module__,
e.__name__,
OrderedDict((name, value) for name, (value, _) in values.items()),
methods=methods,
)
return Class(
e.__module__,
e.__name__,
direct_bases,
methods,

Some files were not shown because too many files have changed in this diff Show More