chore: Add PyPI package support for uvx/pip installation (#1764)

* Initial plan

* Add package structure and resource path utilities

- Created langbot/ package with __init__.py and __main__.py entry point
- Added paths utility to find frontend and resource files from package installation
- Updated config loading to use resource paths
- Updated frontend serving to use resource paths
- Added MANIFEST.in for package data inclusion
- Updated pyproject.toml with build system and entry points

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add PyPI publishing workflow and update license

- Created GitHub Actions workflow to build frontend and publish to PyPI
- Added license field to pyproject.toml to fix deprecation warning
- Updated .gitignore to exclude build artifacts
- Tested package building successfully

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add PyPI installation documentation

- Created PYPI_INSTALLATION.md with detailed installation and usage instructions
- Updated README.md to feature uvx/pip installation as recommended method
- Updated README_EN.md with same changes for English documentation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Address code review feedback

- Made package-data configuration more specific to langbot package only
- Improved path detection with caching to avoid repeated file I/O
- Removed sys.path searching which was incorrect for package data
- Removed interactive input() call for non-interactive environment compatibility
- Simplified error messages for version check

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix code review issues

- Use specific exception types instead of bare except
- Fix misleading comments about directory levels
- Remove redundant existence check before makedirs with exist_ok=True
- Use context manager for file opening to ensure proper cleanup

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Simplify package configuration and document behavioral differences

- Removed redundant package-data configuration, relying on MANIFEST.in
- Added documentation about behavioral differences between package and source installation
- Clarified that include-package-data=true uses MANIFEST.in for data files

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* chore: update pyproject.toml

* chore: try pack templates in langbot/

* chore: update

* chore: update

* chore: update

* chore: update

* chore: update

* chore: adjust dir structure

* chore: fix imports

* fix: read default-pipeline-config.json

* fix: read default-pipeline-config.json

* fix: tests

* ci: publish pypi

* chore: bump version 4.6.0-beta.1 for testing

* chore: add templates/**

* fix: send adapters and requesters icons

* chore: bump version 4.6.0b2 for testing

* chore: add platform field for docker-compose.yaml

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-16 19:53:01 +08:00
committed by GitHub
parent 6a24c951e0
commit e642ffa5b3
477 changed files with 1001 additions and 1002 deletions

View File

View File

View File

@@ -0,0 +1,71 @@
import os
import json
import importlib.resources as resources
from langbot.pkg.config import model as file_model
class JSONConfigFile(file_model.ConfigFile):
"""JSON config file"""
def __init__(
self,
config_file_name: str,
template_resource_name: str = None,
template_data: dict = None,
) -> None:
self.config_file_name = config_file_name
self.template_resource_name = template_resource_name
self.template_data = template_data
def exists(self) -> bool:
return os.path.exists(self.config_file_name)
async def get_template_file_str(self) -> str:
if self.template_resource_name is None:
return None
with (
resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f
):
return f.read()
async def create(self):
if await self.get_template_file_str() is not None:
with open(self.config_file_name, 'w', encoding='utf-8') as f:
f.write(await self.get_template_file_str())
elif self.template_data is not None:
with open(self.config_file_name, 'w', encoding='utf-8') as f:
json.dump(self.template_data, f, indent=4, ensure_ascii=False)
else:
raise ValueError('template_file_name or template_data must be provided')
async def load(self, completion: bool = True) -> dict:
if not self.exists():
await self.create()
template_file_str = await self.get_template_file_str()
if template_file_str is not None:
self.template_data = json.loads(template_file_str)
with open(self.config_file_name, 'r', encoding='utf-8') as f:
try:
cfg = json.load(f)
except json.JSONDecodeError as e:
raise Exception(f'Syntax error in config file {self.config_file_name}: {e}')
if completion:
for key in self.template_data:
if key not in cfg:
cfg[key] = self.template_data[key]
return cfg
async def save(self, cfg: dict):
with open(self.config_file_name, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
def save_sync(self, cfg: dict):
with open(self.config_file_name, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)

View File

@@ -0,0 +1,66 @@
import os
import shutil
import importlib
import logging
from .. import model as file_model
class PythonModuleConfigFile(file_model.ConfigFile):
"""Python module config file"""
config_file_name: str = None
"""Config file name"""
template_file_name: str = None
"""Template file name"""
def __init__(self, config_file_name: str, template_file_name: str) -> None:
self.config_file_name = config_file_name
self.template_file_name = template_file_name
def exists(self) -> bool:
return os.path.exists(self.config_file_name)
async def create(self):
shutil.copyfile(self.template_file_name, self.config_file_name)
async def load(self, completion: bool = True) -> dict:
module_name = os.path.splitext(os.path.basename(self.config_file_name))[0]
module = importlib.import_module(module_name)
cfg = {}
allowed_types = (int, float, str, bool, list, dict)
for key in dir(module):
if key.startswith('__'):
continue
if not isinstance(getattr(module, key), allowed_types):
continue
cfg[key] = getattr(module, key)
# complete from template module file
if completion:
module_name = os.path.splitext(os.path.basename(self.template_file_name))[0]
module = importlib.import_module(module_name)
for key in dir(module):
if key.startswith('__'):
continue
if not isinstance(getattr(module, key), allowed_types):
continue
if key not in cfg:
cfg[key] = getattr(module, key)
return cfg
async def save(self, data: dict):
logging.warning('Python module config file does not support saving')
def save_sync(self, data: dict):
logging.warning('Python module config file does not support saving')

View File

@@ -0,0 +1,71 @@
import os
import yaml
import importlib.resources as resources
from langbot.pkg.config import model as file_model
class YAMLConfigFile(file_model.ConfigFile):
"""YAML config file"""
def __init__(
self,
config_file_name: str,
template_resource_name: str = None,
template_data: dict = None,
) -> None:
self.config_file_name = config_file_name
self.template_resource_name = template_resource_name
self.template_data = template_data
def exists(self) -> bool:
return os.path.exists(self.config_file_name)
async def get_template_file_str(self) -> str:
if self.template_resource_name is None:
return None
with (
resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f
):
return f.read()
async def create(self):
if await self.get_template_file_str() is not None:
with open(self.config_file_name, 'w', encoding='utf-8') as f:
f.write(await self.get_template_file_str())
elif self.template_data is not None:
with open(self.config_file_name, 'w', encoding='utf-8') as f:
yaml.dump(self.template_data, f, indent=4, allow_unicode=True)
else:
raise ValueError('template_file_name or template_data must be provided')
async def load(self, completion: bool = True) -> dict:
if not self.exists():
await self.create()
template_file_str = await self.get_template_file_str()
if template_file_str is not None:
self.template_data = yaml.load(template_file_str, Loader=yaml.FullLoader)
with open(self.config_file_name, 'r', encoding='utf-8') as f:
try:
cfg = yaml.load(f, Loader=yaml.FullLoader)
except yaml.YAMLError as e:
raise Exception(f'Syntax error in config file {self.config_file_name}: {e}')
if completion:
for key in self.template_data:
if key not in cfg:
cfg[key] = self.template_data[key]
return cfg
async def save(self, cfg: dict):
with open(self.config_file_name, 'w', encoding='utf-8') as f:
yaml.dump(cfg, f, indent=4, allow_unicode=True)
def save_sync(self, cfg: dict):
with open(self.config_file_name, 'w', encoding='utf-8') as f:
yaml.dump(cfg, f, indent=4, allow_unicode=True)

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from . import model as file_model
from .impls import pymodule, json as json_file, yaml as yaml_file
class ConfigManager:
"""Config file manager"""
name: str = None
"""Config manager name"""
description: str = None
"""Config manager description"""
schema: dict = None
"""Config file schema
Must conform to JSON Schema Draft 7 specification
"""
file: file_model.ConfigFile = None
"""Config file instance"""
data: dict = None
"""Config data"""
doc_link: str = None
"""Config file documentation link"""
def __init__(self, cfg_file: file_model.ConfigFile) -> None:
self.file = cfg_file
self.data = {}
async def load_config(self, completion: bool = True):
self.data = await self.file.load(completion=completion)
async def dump_config(self):
await self.file.save(self.data)
def dump_config_sync(self):
self.file.save_sync(self.data)
async def load_python_module_config(config_name: str, template_name: str, completion: bool = True) -> ConfigManager:
"""Load Python module config file
Args:
config_name (str): Config file name
template_name (str): Template file name
completion (bool): Whether to automatically complete the config file in memory
Returns:
ConfigManager: Config file manager
"""
cfg_inst = pymodule.PythonModuleConfigFile(config_name, template_name)
cfg_mgr = ConfigManager(cfg_inst)
await cfg_mgr.load_config(completion=completion)
return cfg_mgr
async def load_json_config(
config_name: str,
template_resource_name: str = None,
template_data: dict = None,
completion: bool = True,
) -> ConfigManager:
"""Load JSON config file
Args:
config_name (str): Config file name
template_resource_name (str): Template resource name
template_data (dict): Template data
completion (bool): Whether to automatically complete the config file in memory
"""
cfg_inst = json_file.JSONConfigFile(config_name, template_resource_name, template_data)
cfg_mgr = ConfigManager(cfg_inst)
await cfg_mgr.load_config(completion=completion)
return cfg_mgr
async def load_yaml_config(
config_name: str,
template_resource_name: str = None,
template_data: dict = None,
completion: bool = True,
) -> ConfigManager:
"""Load YAML config file
Args:
config_name (str): Config file name
template_resource_name (str): Template resource name
template_data (dict): Template data
completion (bool): Whether to automatically complete the config file in memory
Returns:
ConfigManager: Config file manager
"""
cfg_inst = yaml_file.YAMLConfigFile(config_name, template_resource_name, template_data)
cfg_mgr = ConfigManager(cfg_inst)
await cfg_mgr.load_config(completion=completion)
return cfg_mgr

View File

@@ -0,0 +1,34 @@
import abc
class ConfigFile(metaclass=abc.ABCMeta):
"""Config file abstract class"""
config_file_name: str = None
"""Config file name"""
template_file_name: str = None
"""Template file name"""
template_data: dict = None
"""Template data"""
@abc.abstractmethod
def exists(self) -> bool:
pass
@abc.abstractmethod
async def create(self):
pass
@abc.abstractmethod
async def load(self, completion: bool = True) -> dict:
pass
@abc.abstractmethod
async def save(self, data: dict):
pass
@abc.abstractmethod
def save_sync(self, data: dict):
pass