diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
new file mode 100644
index 0000000..042f655
--- /dev/null
+++ b/.github/workflows/publish-to-pypi.yml
@@ -0,0 +1,86 @@
+name: Publish to PyPI
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ build:
+ name: Build distribution
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Extract version from tag
+ id: get_version
+ run: |
+ # Strip 'v' prefix from tag (v0.2.1 -> 0.2.1)
+ VERSION=${GITHUB_REF_NAME#v}
+ echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
+ echo "Extracted version: $VERSION"
+
+ - name: Update version in pyproject.toml
+ run: |
+ sed -i "s/^version = .*/version = \"${{ steps.get_version.outputs.VERSION }}\"/" pyproject.toml
+ echo "Updated pyproject.toml:"
+ grep "^version" pyproject.toml
+
+ - name: Commit version update to repo
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add pyproject.toml
+ git commit -m "Bump version to ${{ steps.get_version.outputs.VERSION }}"
+ git push origin HEAD:main
+
+ - name: Install build dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install build
+
+ - name: Build package
+ run: python -m build
+
+ - name: Store the distribution packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: python-package-distributions
+ path: dist/
+
+ - name: Upload assets to GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release upload ${{ github.ref_name }} dist/* --clobber
+
+ publish-to-pypi:
+ name: Publish to PyPI
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/p/codeplain
+
+ steps:
+ - name: Download distribution packages
+ uses: actions/download-artifact@v4
+ with:
+ name: python-package-distributions
+ path: dist/
+
+ - name: Install twine
+ run: pip install twine
+
+ - name: Publish to PyPI
+ env:
+ TWINE_USERNAME: __token__
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+ run: twine upload dist/*
diff --git a/.gitignore b/.gitignore
index 07a72f2..a4af275 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,5 +23,8 @@ examples/**/node_plain_modules/
*.log
.venv
+build
+dist
+*.egg-info
.env
\ No newline at end of file
diff --git a/README.md b/README.md
index 30c96de..46fad81 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Codeplain is a platform that generates software code using large language models
Schematic overview of the Codeplain's code generation service
-
+
### Abstracting Away Code Generation Complexity with ***plain
@@ -17,7 +17,7 @@ Schematic overview of the Codeplain's code generation service
An example application in ***plain
-
+
## Getting started
diff --git a/config/__init__.py b/config/__init__.py
new file mode 100644
index 0000000..08701da
--- /dev/null
+++ b/config/__init__.py
@@ -0,0 +1 @@
+# Configuration files for plain2code
diff --git a/system_config.yaml b/config/system_config.yaml
similarity index 100%
rename from system_config.yaml
rename to config/system_config.yaml
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..03edca7
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,217 @@
+#!/bin/bash
+
+set -e
+
+# Brand Colors (True Color / 24-bit)
+YELLOW='\033[38;2;224;255;110m' # #E0FF6E
+GREEN='\033[38;2;121;252;150m' # #79FC96
+GREEN_LIGHT='\033[38;2;197;220;217m' # #C5DCD9
+GREEN_DARK='\033[38;2;34;57;54m' # #223936
+BLUE='\033[38;2;10;31;212m' # #0A1FD4
+BLACK='\033[38;2;26;26;26m' # #1A1A1A
+WHITE='\033[38;2;255;255;255m' # #FFFFFF
+RED='\033[38;2;239;68;68m' # #EF4444
+GRAY='\033[38;2;128;128;128m' # #808080
+GRAY_LIGHT='\033[38;2;211;211;211m' # #D3D3D3
+BOLD='\033[1m'
+NC='\033[0m' # No Color / Reset
+
+# Required Python version
+REQUIRED_MAJOR=3
+REQUIRED_MINOR=11
+
+# Detect OS
+detect_os() {
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ echo "macos"
+ elif [[ -f /etc/debian_version ]]; then
+ echo "debian"
+ elif [[ -f /etc/redhat-release ]]; then
+ echo "redhat"
+ else
+ echo "unknown"
+ fi
+}
+
+echo -e "started ${YELLOW}${BOLD}*codeplain CLI${NC} installation..."
+
+# Install Python based on OS
+install_python() {
+ local os=$(detect_os)
+
+ case $os in
+ macos)
+ if command -v brew &> /dev/null; then
+ echo -e "installing Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} via Homebrew..."
+ brew install python@${REQUIRED_MAJOR}.${REQUIRED_MINOR}
+ else
+ echo -e "${RED}Error: Homebrew is not installed.${NC}"
+ echo "please install Homebrew first: https://brew.sh"
+ echo "or install Python manually from: https://www.python.org/downloads/"
+ exit 1
+ fi
+ ;;
+ debian)
+ echo -e "installing Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} via apt..."
+ sudo apt update
+ sudo apt install -y python${REQUIRED_MAJOR}.${REQUIRED_MINOR} python${REQUIRED_MAJOR}.${REQUIRED_MINOR}-venv python3-pip
+ ;;
+ redhat)
+ echo -e "installing Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} via dnf..."
+ sudo dnf install -y python${REQUIRED_MAJOR}.${REQUIRED_MINOR}
+ ;;
+ *)
+ echo -e "${RED}Error: Automatic installation not supported for your OS.${NC}"
+ echo "please install Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} manually from:"
+ echo " https://www.python.org/downloads/"
+ exit 1
+ ;;
+ esac
+}
+
+# Prompt user to install Python
+prompt_install_python() {
+ echo ""
+ read -p "$(echo -e ${YELLOW}would you like to install Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR}? \(Y/n\): ${NC})" response
+ case "$response" in
+ [yY][eE][sS]|[yY]|"")
+ install_python
+ echo ""
+ echo -e "${GREEN}✓ python installed.${NC} please restart your terminal and run this script again."
+ exit 0
+ ;;
+ *)
+ echo -e "${YELLOW}installation cancelled.${NC}"
+ exit 1
+ ;;
+ esac
+}
+
+# Check if python3 or python is installed
+if command -v python3.11 &> /dev/null; then
+ PYTHON_CMD="python3.11"
+elif command -v python3 &> /dev/null; then
+ PYTHON_CMD="python3"
+elif command -v python &> /dev/null; then
+ PYTHON_CMD="python"
+else
+ echo -e "${RED}error: Python 3 is not installed.${NC}"
+ prompt_install_python
+fi
+
+# Get Python version
+PYTHON_VERSION=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
+PYTHON_MAJOR=$(echo "$PYTHON_VERSION" | cut -d. -f1)
+PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2)
+
+# Check version
+if [ "$PYTHON_MAJOR" -lt "$REQUIRED_MAJOR" ] || \
+ ([ "$PYTHON_MAJOR" -eq "$REQUIRED_MAJOR" ] && [ "$PYTHON_MINOR" -lt "$REQUIRED_MINOR" ]); then
+ echo -e "${RED}error: Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} or greater is required.${NC}"
+ echo -e "found: python ${YELLOW}${PYTHON_VERSION}${NC}"
+ prompt_install_python
+fi
+
+echo -e ""
+echo -e "${GREEN}✓${NC} python ${BOLD}${PYTHON_VERSION}${NC} detected"
+echo -e ""
+# Use python -m pip for reliability
+PIP_CMD="$PYTHON_CMD -m pip"
+
+# Install or upgrade codeplain
+if $PIP_CMD show codeplain &> /dev/null; then
+ CURRENT_VERSION=$($PIP_CMD show codeplain | grep "^Version:" | cut -d' ' -f2)
+ echo -e "${GRAY}codeplain ${CURRENT_VERSION} is already installed.${NC}"
+ echo -e "upgrading to latest version..."
+ echo -e ""
+ $PIP_CMD install --upgrade codeplain &> /dev/null
+ NEW_VERSION=$($PIP_CMD show codeplain | grep "^Version:" | cut -d' ' -f2)
+ if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
+ echo -e "${GREEN}✓${NC} codeplain is already up to date (${NEW_VERSION})"
+ else
+ echo -e "${GREEN}✓${NC} codeplain upgraded from ${CURRENT_VERSION} to ${NEW_VERSION}!"
+ fi
+else
+ echo -e "installing codeplain...${NC}"
+ echo -e ""
+ $PIP_CMD install codeplain &> /dev/null
+ echo -e "${GREEN}✓ codeplain installed successfully!${NC}"
+fi
+
+echo -e "${GREEN}✓${NC} the latest version of *codeplain CLI is now installed."
+echo ""
+echo -e "go to ${YELLOW}https://platform.codeplain.ai${NC} and sign up to get your API key."
+echo ""
+read -p "paste your API key here: " API_KEY
+echo ""
+
+if [ -z "$API_KEY" ]; then
+ echo -e "${GRAY}no API key provided. you can set it later with:${NC}"
+ echo -e " export CODEPLAIN_API_KEY=\"your_api_key\""
+else
+ # Export for current session
+ export CODEPLAIN_API_KEY="$API_KEY"
+
+ # Detect user's default shell from $SHELL (works even when script runs in different shell)
+ case "$SHELL" in
+ */zsh)
+ SHELL_RC="$HOME/.zprofile"
+ ;;
+ */bash)
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ # macOS uses .bash_profile for login shells
+ SHELL_RC="$HOME/.bash_profile"
+ else
+ SHELL_RC="$HOME/.bashrc"
+ fi
+ ;;
+ *)
+ SHELL_RC="$HOME/.profile"
+ ;;
+ esac
+
+ # Create the file if it doesn't exist
+ touch "$SHELL_RC"
+
+ # Add to shell config if not already present
+ if ! grep -q "CODEPLAIN_API_KEY" "$SHELL_RC" 2>/dev/null; then
+ echo "" >> "$SHELL_RC"
+ echo "# codeplain API Key" >> "$SHELL_RC"
+ echo "export CODEPLAIN_API_KEY=\"$API_KEY\"" >> "$SHELL_RC"
+ echo -e "${GREEN}✓ API key saved to ${SHELL_RC}${NC}"
+ else
+ # Update existing key (different sed syntax for macOS vs Linux)
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ sed -i '' "s|export CODEPLAIN_API_KEY=.*|export CODEPLAIN_API_KEY=\"$API_KEY\"|" "$SHELL_RC"
+ else
+ sed -i "s|export CODEPLAIN_API_KEY=.*|export CODEPLAIN_API_KEY=\"$API_KEY\"|" "$SHELL_RC"
+ fi
+ echo -e "${GREEN}✓${NC} API key added to ${SHELL_RC}"
+ fi
+
+fi
+
+# ASCII Art Welcome
+echo ""
+echo -e "${NC}"
+echo -e "${GRAY}────────────────────────────────────────────${NC}"
+echo -e ""
+cat << 'EOF'
+ _ _ _
+ ___ ___ __| | ___ _ __ | | __ _(_)_ __
+ / __/ _ \ / _` |/ _ \ '_ \| |/ _` | | '_ \
+ | (_| (_) | (_| | __/ |_) | | (_| | | | | |
+ \___\___/ \__,_|\___| .__/|_|\__,_|_|_| |_|
+ |_|
+EOF
+echo ""
+echo -e " ${YELLOW}welcome to *codeplain!${NC}"
+echo ""
+echo -e " spec-driven, production-ready code generation"
+echo ""
+echo ""
+echo -e "${GRAY}────────────────────────────────────────────${NC}"
+echo ""
+echo -e " thank you for using *codeplain!"
+echo ""
+echo -e " run '${YELLOW}${BOLD}codeplain ${NC}' to get started."
diff --git a/plain2code.py b/plain2code.py
index 1cae9a5..b4e2cd7 100644
--- a/plain2code.py
+++ b/plain2code.py
@@ -1,4 +1,4 @@
-import importlib.util
+import importlib.resources
import logging
import logging.config
import os
@@ -9,6 +9,7 @@
from liquid2.exceptions import TemplateNotFoundError
from requests.exceptions import RequestException
+import codeplain_REST_api as codeplain_api
import file_utils
import plain_file
import plain_spec
@@ -26,14 +27,12 @@
get_log_file_path,
)
from plain2code_state import RunState
-from plain2code_utils import print_dry_run_output
from system_config import system_config
from tui.plain2code_tui import Plain2CodeTUI
TEST_SCRIPT_EXECUTION_TIMEOUT = 120 # 120 seconds
-LOGGING_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logging_config.yaml")
-DEFAULT_TEMPLATE_DIRS = "standard_template_library"
+DEFAULT_TEMPLATE_DIRS = importlib.resources.files("standard_template_library")
MAX_UNITTEST_FIX_ATTEMPTS = 20
MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20
@@ -89,6 +88,7 @@ def _get_frids_range(plain_source, start, end=None):
def setup_logging(
+ args,
event_bus: EventBus,
log_to_file: bool,
log_file_name: str,
@@ -104,32 +104,21 @@ def setup_logging(
logging.getLogger("git").setLevel(logging.WARNING)
logging.getLogger("anthropic._base_client").setLevel(logging.WARNING)
logging.getLogger("services.langsmith.langsmith_service").setLevel(logging.WARNING)
- logging.getLogger("transitions").setLevel(logging.WARNING)
logging.getLogger("repositories").setLevel(logging.WARNING)
+ logging.getLogger("transitions").setLevel(logging.ERROR)
+ logging.getLogger("transitions.extensions.diagrams").setLevel(logging.ERROR)
log_file_path = get_log_file_path(plain_file_path, log_file_name)
# Try to load logging configuration from YAML file
- if os.path.exists(LOGGING_CONFIG_PATH):
+ if args.logging_config_path and os.path.exists(args.logging_config_path):
try:
- with open(LOGGING_CONFIG_PATH, "r") as f:
+ with open(args.logging_config_path, "r") as f:
config = yaml.safe_load(f)
logging.config.dictConfig(config)
- console.info(f"Loaded logging configuration from {LOGGING_CONFIG_PATH}")
+ console.info(f"Loaded logging configuration from {args.logging_config_path}")
except Exception as e:
- logging.basicConfig()
- console.warning(f"Failed to load logging configuration from {LOGGING_CONFIG_PATH}: {str(e)}")
-
- # Silence noisy third-party libraries
- logging.getLogger("urllib3").setLevel(logging.WARNING)
- logging.getLogger("httpx").setLevel(logging.WARNING)
- logging.getLogger("httpcore").setLevel(logging.WARNING)
- logging.getLogger("anthropic").setLevel(logging.WARNING)
- logging.getLogger("langsmith").setLevel(logging.WARNING)
- logging.getLogger("git").setLevel(logging.WARNING)
- logging.getLogger("openai").setLevel(logging.WARNING)
- logging.getLogger("transitions").setLevel(logging.WARNING)
- logging.getLogger("google_genai").setLevel(logging.WARNING)
+ console.warning(f"Failed to load logging configuration from {args.logging_config_path}: {str(e)}")
# Allow detailed retry logs for anthropic if needed
logging.getLogger("anthropic._base_client").setLevel(logging.DEBUG)
@@ -170,29 +159,12 @@ def render(args, run_state: RunState, codeplain_api, event_bus: EventBus): # no
template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS)
- _, plain_source, _ = plain_file.plain_file_parser(args.filename, template_dirs)
-
- if args.render_range is not None:
- args.render_range = get_render_range(args.render_range, plain_source)
- elif args.render_from is not None:
- args.render_range = get_render_range_from(args.render_from, plain_source)
-
- # Handle dry run and full plain here (outside of state machine)
- if args.dry_run:
- console.info("Printing dry run output...")
- print_dry_run_output(plain_source, args.render_range)
- return
-
- if args.full_plain:
- console.info("Printing full plain output...")
- console.info(plain_source)
- return
+ console.info(f"Rendering {args.filename} to target code.")
codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console)
codeplainAPI.verbose = args.verbose
-
- if args.api:
- codeplainAPI.api_url = args.api
+ assert args.api is not None and args.api != "", "API URL is required"
+ codeplainAPI.api_url = args.api
module_renderer = ModuleRenderer(
codeplainAPI,
@@ -223,22 +195,15 @@ def render(args, run_state: RunState, codeplain_api, event_bus: EventBus): # no
return
-def main(): # noqa: C901
+def main():
args = parse_arguments()
event_bus = EventBus()
- setup_logging(event_bus, args.log_to_file, args.log_file_name, args.filename)
-
- codeplain_api_module_name = "codeplain_local_api"
+ setup_logging(args, event_bus, args.log_to_file, args.log_file_name, args.filename)
- codeplain_api_spec = importlib.util.find_spec(codeplain_api_module_name)
- if args.api or codeplain_api_spec is None:
- if not args.api:
- args.api = "https://api.codeplain.ai"
- import codeplain_REST_api as codeplain_api
- else:
- codeplain_api = importlib.import_module(codeplain_api_module_name)
+ if not args.api:
+ args.api = "https://api.codeplain.ai"
run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with)
diff --git a/plain2code_arguments.py b/plain2code_arguments.py
index 3fb3bc3..35c7db4 100644
--- a/plain2code_arguments.py
+++ b/plain2code_arguments.py
@@ -276,6 +276,13 @@ def create_parser():
help="If set, render the state machine graph.",
)
+ parser.add_argument(
+ "--logging-config-path",
+ action="store_true",
+ default="logging_config.yaml",
+ help="Path to the logging configuration file.",
+ )
+
return parser
diff --git a/pyproject.toml b/pyproject.toml
index ff8b442..08dd61a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,93 @@
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "codeplain"
+version = "0.1.6"
+description = "Transform plain language specifications into working code"
+readme = "README.md"
+requires-python = ">=3.11"
+classifiers = [
+ "Environment :: Console",
+ "Intended Audience :: Developers",
+ "Operating System :: OS Independent",
+ "Topic :: Software Development :: Code Generators",
+]
+dependencies = [
+ "python-liquid2==0.3.0",
+ "mistletoe==1.3.0",
+ "requests==2.32.3",
+ "tiktoken==0.12.0",
+ "PyYAML==6.0.2",
+ "gitpython==3.1.42",
+ "transitions==0.9.3",
+ "textual==1.0.0",
+ "rich==14.2.0",
+ "python-frontmatter==1.1.0",
+ "networkx==3.6.1"
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest==8.3.5",
+ "flake8==7.0.0",
+ "black==24.2.0",
+ "isort==5.13.2",
+ "mypy==1.11.2",
+]
+
+[project.scripts]
+codeplain = "plain2code:main"
+
+[tool.setuptools]
+py-modules = [
+ "plain2code",
+ "plain2code_arguments",
+ "plain2code_console",
+ "plain2code_events",
+ "plain2code_exceptions",
+ "plain2code_nodes",
+ "plain2code_read_config",
+ "plain2code_state",
+ "plain2code_tui",
+ "plain2code_utils",
+ "plain_file",
+ "plain_spec",
+ "codeplain_constants",
+ "codeplain_local_api",
+ "codeplain_models",
+ "codeplain_REST_api",
+ "codeplain_types",
+ "codeplain_utils",
+ "codeplain",
+ "code_complexity",
+ "config",
+ "content_extractor",
+ "event_bus",
+ "file_utils",
+ "git_utils",
+ "hash_key",
+ "llm_exceptions",
+ "llm_handler",
+ "llm_selector",
+ "llm",
+ "render_cache",
+ "spinner",
+ "system_config",
+ "tui_components",
+ "concept_utils",
+ "module_renderer",
+ "plain_modules",
+ "plain2code_logger",
+]
+
+[tool.setuptools.packages.find]
+include = ["config*", "patch*", "prompt_templates*", "render_machine*", "repositories*", "services*", "standard_template_library*", "tui*"]
+
+[tool.setuptools.package-data]
+"*" = ["*.yaml", "*.css", "*.plain"]
+
[tool.black]
line-length = 120
target-version = ['py311']
@@ -19,11 +109,6 @@ line_length = 120
skip = [".git", ".venv", "dist", "venv", ".conda", "tests/data"]
skip_gitignore = true
-[tool.pytest.ini_options]
-pythonpath = ["src"]
-testpaths = ["tests"]
-norecursedirs = ["tests/data"]
-
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
@@ -61,3 +146,8 @@ exclude = [
"^src/services/langsmith/",
"^deploy/",
]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests"]
+norecursedirs = ["tests/data"]
\ No newline at end of file
diff --git a/standard_template_library/__init__.py b/standard_template_library/__init__.py
new file mode 100644
index 0000000..35e9cd5
--- /dev/null
+++ b/standard_template_library/__init__.py
@@ -0,0 +1 @@
+# Standard template library for plain2code
diff --git a/system_config.py b/system_config.py
index a9a42a2..8af040f 100644
--- a/system_config.py
+++ b/system_config.py
@@ -1,4 +1,4 @@
-import os
+import importlib.resources
import shutil
import sys
@@ -22,9 +22,9 @@ def __init__(self):
def _load_config(self):
"""Load system configuration from YAML file."""
- config_path = os.path.join(os.path.dirname(__file__), "system_config.yaml")
+ config_path = importlib.resources.files("config").joinpath("system_config.yaml")
try:
- with open(config_path, "r") as f:
+ with config_path.open("r") as f:
yaml_data = yaml.safe_load(f)
return yaml_data
except Exception as e: