assayflux 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AssayFlux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: assayflux
3
+ Version: 0.4.0
4
+ Summary: Python-driven multi-language project generation, testing, and build orchestration
5
+ Author: AssayFlux contributors
6
+ License-Expression: MIT
7
+ Keywords: build-tool,project-generator,android,apk,aab,multi-language
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0; extra == "dev"
25
+ Requires-Dist: build>=1.2; extra == "dev"
26
+ Requires-Dist: twine>=5.1; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # AssayFlux
30
+
31
+ **AssayFlux is a Python-based multi-language project generator and build orchestrator.** It creates starter workspaces, plans tasks, detects local toolchains, runs supported project targets, packages source into a separate delivery folder, and can orchestrate Android debug APK and release AAB builds when a compatible toolchain is configured.
32
+
33
+ AssayFlux is a project toolkit, not a new programming language and not a transpiler: it does not translate arbitrary source code between languages.
34
+
35
+ ## Requirements
36
+
37
+ - Python 3.10 or newer
38
+ - Optional per-target toolchains: Node.js, C/C++ compiler, JDK, Kotlin compiler, PHP, Bash
39
+ - Android builds: JDK 17, Gradle 8.9 compatible with Android Gradle Plugin 8.7.3, Android SDK platform 35 and build-tools 35.0.0, and `ANDROID_HOME` or `ANDROID_SDK_ROOT`
40
+
41
+ ## Install for development
42
+
43
+ ```bash
44
+ python -m pip install -e '.[dev]'
45
+ python -m pytest -q
46
+ assayflux --version
47
+ assayflux info
48
+ assayflux engines
49
+ assayflux doctor
50
+ ```
51
+
52
+ ## Quick start
53
+
54
+ ```bash
55
+ assayflux new ResearchSite --target website --target backend --feature offline_database
56
+ cd ResearchSite
57
+ assayflux plan
58
+ assayflux test
59
+ assayflux build
60
+ assayflux run --target backend
61
+ ```
62
+
63
+ Open `http://127.0.0.1:8080/api/health` for the generated local backend while it is running. The starter backend binds to loopback only. The website can be served using `assayflux run --target website` and then opened at `http://127.0.0.1:8000`.
64
+
65
+ ## Supported targets
66
+
67
+ `website`, `html`, `css`, `backend`, `cli`, `python`, `c`, `cpp`, `javascript`, `java`, `kotlin`, `php`, `bash`, `android`.
68
+
69
+ ```bash
70
+ assayflux new NativeDemo --target android
71
+ cd NativeDemo
72
+ assayflux build --android-artifacts apk # debug APK
73
+ assayflux build --android-artifacts aab # release AAB
74
+ assayflux build --android-artifacts both # both outputs
75
+ ```
76
+
77
+ APK and AAB are separate build tasks and can be requested independently. Android build failures or missing tools are recorded honestly in `BUILD_REPORT.json`; a source delivery is not described as a successful Android build when binaries are missing. Read [docs/GOOGLE_COLAB_ANDROID.md](docs/GOOGLE_COLAB_ANDROID.md) to test builds in Google Colab.
78
+
79
+ ## Delivery structure
80
+
81
+ ```text
82
+ workspace/
83
+ ResearchSite/ # Editable project
84
+ .assayflux/project.json
85
+ .assayflux/plan.json
86
+ web/ # if website target selected
87
+ backend_app.py # if backend target selected
88
+ tests/
89
+ deliverables/ResearchSite/
90
+ source/ # Source snapshot
91
+ artifacts/ # Produced APK/AAB or other build artifacts
92
+ logs/ # Android build stdout/stderr
93
+ BUILD_REPORT.json # Machine-readable status
94
+ DELIVERY_README.md
95
+ ```
96
+
97
+ `assayflux build` will not overwrite an existing delivery folder unless `--force` is explicitly passed. `--force` only replaces the delivery folder, not the editable source workspace.
98
+
99
+ ## Commands
100
+
101
+ - `assayflux info`, `assayflux --version`
102
+ - `assayflux engines` — engine capabilities/tool detection
103
+ - `assayflux doctor [--json]` — toolchain diagnostics
104
+ - `assayflux new NAME [--root DIR] [--target TARGET] [--feature FEATURE]`
105
+ - `assayflux plan [PROJECT_DIR]`
106
+ - `assayflux test [PROJECT_DIR]`
107
+ - `assayflux build [PROJECT_DIR] [--force] [--android-artifacts apk|aab|both|none]`
108
+ - `assayflux run --project-dir DIR --target TARGET`
109
+
110
+ ## Python API
111
+
112
+ ```python
113
+ from assayflux import Project
114
+
115
+ project = Project.create("ResearchPortal", root=".", targets=["website", "backend"], features=["offline_database", "data_analysis"])
116
+ project.generate()
117
+ result = project.build()
118
+ print(result.delivery_dir)
119
+ ```
120
+
121
+ ## Security and limitations
122
+
123
+ - Build/test/run commands can execute generated or project-local code. Do not run untrusted projects; this is not a sandbox.
124
+ - Generated backend starter code is a minimal example, not a hardened internet-facing API. Keep it on loopback unless you add authentication, TLS, rate limits, and deployment hardening.
125
+ - Android release AAB signing is not configured automatically. Configure signing secrets securely and test on devices before store release.
126
+ - Non-Android `build` currently snapshots source and reports packaging; it does not claim to compile every language target. Use `run` and that target's toolchain to compile/run supported examples.
127
+ - The normalized PyPI endpoint for `assayflux` returned not-found during the preparation check, but PyPI name availability must still be confirmed while creating the project under your own account. Also, this package uses the Python import namespace `assayflux`; do not install it in the same environment as your older, separate AssayFlux interpreter package if that package also uses the same import namespace. Configure a Trusted Publisher or API token. A ZIP/build check cannot publish the package for you.
128
+
129
+ ## Release build
130
+
131
+ ```bash
132
+ python -m pip install --upgrade build twine
133
+ python -m pytest -q
134
+ python -m build
135
+ python -m twine check dist/*
136
+ ```
137
+
138
+ For GitHub Actions publishing, configure PyPI Trusted Publishing for the repository and workflow `.github/workflows/publish.yml`, then create a GitHub Release matching the version in `pyproject.toml`.
139
+
140
+ ## Google Colab release workflow
141
+
142
+ Use `AssayFlux_Colab_Full_Test_then_PyPI.ipynb` to test the source, build fresh distributions, validate the wheel and source archive, test the built wheel in isolation, and only then optionally publish to real PyPI. This workflow skips TestPyPI and requires an explicit production-publish confirmation.
@@ -0,0 +1,114 @@
1
+ # AssayFlux
2
+
3
+ **AssayFlux is a Python-based multi-language project generator and build orchestrator.** It creates starter workspaces, plans tasks, detects local toolchains, runs supported project targets, packages source into a separate delivery folder, and can orchestrate Android debug APK and release AAB builds when a compatible toolchain is configured.
4
+
5
+ AssayFlux is a project toolkit, not a new programming language and not a transpiler: it does not translate arbitrary source code between languages.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.10 or newer
10
+ - Optional per-target toolchains: Node.js, C/C++ compiler, JDK, Kotlin compiler, PHP, Bash
11
+ - Android builds: JDK 17, Gradle 8.9 compatible with Android Gradle Plugin 8.7.3, Android SDK platform 35 and build-tools 35.0.0, and `ANDROID_HOME` or `ANDROID_SDK_ROOT`
12
+
13
+ ## Install for development
14
+
15
+ ```bash
16
+ python -m pip install -e '.[dev]'
17
+ python -m pytest -q
18
+ assayflux --version
19
+ assayflux info
20
+ assayflux engines
21
+ assayflux doctor
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ```bash
27
+ assayflux new ResearchSite --target website --target backend --feature offline_database
28
+ cd ResearchSite
29
+ assayflux plan
30
+ assayflux test
31
+ assayflux build
32
+ assayflux run --target backend
33
+ ```
34
+
35
+ Open `http://127.0.0.1:8080/api/health` for the generated local backend while it is running. The starter backend binds to loopback only. The website can be served using `assayflux run --target website` and then opened at `http://127.0.0.1:8000`.
36
+
37
+ ## Supported targets
38
+
39
+ `website`, `html`, `css`, `backend`, `cli`, `python`, `c`, `cpp`, `javascript`, `java`, `kotlin`, `php`, `bash`, `android`.
40
+
41
+ ```bash
42
+ assayflux new NativeDemo --target android
43
+ cd NativeDemo
44
+ assayflux build --android-artifacts apk # debug APK
45
+ assayflux build --android-artifacts aab # release AAB
46
+ assayflux build --android-artifacts both # both outputs
47
+ ```
48
+
49
+ APK and AAB are separate build tasks and can be requested independently. Android build failures or missing tools are recorded honestly in `BUILD_REPORT.json`; a source delivery is not described as a successful Android build when binaries are missing. Read [docs/GOOGLE_COLAB_ANDROID.md](docs/GOOGLE_COLAB_ANDROID.md) to test builds in Google Colab.
50
+
51
+ ## Delivery structure
52
+
53
+ ```text
54
+ workspace/
55
+ ResearchSite/ # Editable project
56
+ .assayflux/project.json
57
+ .assayflux/plan.json
58
+ web/ # if website target selected
59
+ backend_app.py # if backend target selected
60
+ tests/
61
+ deliverables/ResearchSite/
62
+ source/ # Source snapshot
63
+ artifacts/ # Produced APK/AAB or other build artifacts
64
+ logs/ # Android build stdout/stderr
65
+ BUILD_REPORT.json # Machine-readable status
66
+ DELIVERY_README.md
67
+ ```
68
+
69
+ `assayflux build` will not overwrite an existing delivery folder unless `--force` is explicitly passed. `--force` only replaces the delivery folder, not the editable source workspace.
70
+
71
+ ## Commands
72
+
73
+ - `assayflux info`, `assayflux --version`
74
+ - `assayflux engines` — engine capabilities/tool detection
75
+ - `assayflux doctor [--json]` — toolchain diagnostics
76
+ - `assayflux new NAME [--root DIR] [--target TARGET] [--feature FEATURE]`
77
+ - `assayflux plan [PROJECT_DIR]`
78
+ - `assayflux test [PROJECT_DIR]`
79
+ - `assayflux build [PROJECT_DIR] [--force] [--android-artifacts apk|aab|both|none]`
80
+ - `assayflux run --project-dir DIR --target TARGET`
81
+
82
+ ## Python API
83
+
84
+ ```python
85
+ from assayflux import Project
86
+
87
+ project = Project.create("ResearchPortal", root=".", targets=["website", "backend"], features=["offline_database", "data_analysis"])
88
+ project.generate()
89
+ result = project.build()
90
+ print(result.delivery_dir)
91
+ ```
92
+
93
+ ## Security and limitations
94
+
95
+ - Build/test/run commands can execute generated or project-local code. Do not run untrusted projects; this is not a sandbox.
96
+ - Generated backend starter code is a minimal example, not a hardened internet-facing API. Keep it on loopback unless you add authentication, TLS, rate limits, and deployment hardening.
97
+ - Android release AAB signing is not configured automatically. Configure signing secrets securely and test on devices before store release.
98
+ - Non-Android `build` currently snapshots source and reports packaging; it does not claim to compile every language target. Use `run` and that target's toolchain to compile/run supported examples.
99
+ - The normalized PyPI endpoint for `assayflux` returned not-found during the preparation check, but PyPI name availability must still be confirmed while creating the project under your own account. Also, this package uses the Python import namespace `assayflux`; do not install it in the same environment as your older, separate AssayFlux interpreter package if that package also uses the same import namespace. Configure a Trusted Publisher or API token. A ZIP/build check cannot publish the package for you.
100
+
101
+ ## Release build
102
+
103
+ ```bash
104
+ python -m pip install --upgrade build twine
105
+ python -m pytest -q
106
+ python -m build
107
+ python -m twine check dist/*
108
+ ```
109
+
110
+ For GitHub Actions publishing, configure PyPI Trusted Publishing for the repository and workflow `.github/workflows/publish.yml`, then create a GitHub Release matching the version in `pyproject.toml`.
111
+
112
+ ## Google Colab release workflow
113
+
114
+ Use `AssayFlux_Colab_Full_Test_then_PyPI.ipynb` to test the source, build fresh distributions, validate the wheel and source archive, test the built wheel in isolation, and only then optionally publish to real PyPI. This workflow skips TestPyPI and requires an explicit production-publish confirmation.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "assayflux"
7
+ version = "0.4.0"
8
+ description = "Python-driven multi-language project generation, testing, and build orchestration"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "AssayFlux contributors"}]
13
+ keywords = ["build-tool", "project-generator", "android", "apk", "aab", "multi-language"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Software Development :: Build Tools",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0", "build>=1.2", "twine>=5.1"]
32
+
33
+ [project.scripts]
34
+ assayflux = "assayflux.cli:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ pythonpath = ["src"]
41
+ testpaths = ["tests"]
42
+ addopts = "-ra"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """AssayFlux: multi-language project generation and build orchestration."""
2
+ from .errors import AssayFluxError, BuildError, ProjectNameError, ProjectPathError
3
+ from .project import Project, VALID_TARGETS
4
+ __version__ = "0.4.0"
5
+ __all__ = ["Project", "VALID_TARGETS", "AssayFluxError", "BuildError", "ProjectNameError", "ProjectPathError", "__version__"]
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+ import json, os, shutil, subprocess, sys, time
3
+ from pathlib import Path
4
+ from .errors import BuildError, ProjectPathError
5
+ from . import __version__
6
+
7
+ def find_project(path):
8
+ path = Path(path).expanduser().resolve()
9
+ if (path / ".assayflux" / "project.json").is_file(): return path
10
+ raise BuildError(f"{path} is not an AssayFlux project (missing .assayflux/project.json).")
11
+
12
+ def load_metadata(path):
13
+ project_dir = find_project(path)
14
+ try: data = json.loads((project_dir / ".assayflux/project.json").read_text(encoding="utf-8"))
15
+ except (OSError, json.JSONDecodeError) as exc: raise BuildError(f"Cannot read project metadata: {exc}") from exc
16
+ if not isinstance(data, dict) or not isinstance(data.get("name"), str): raise BuildError("Project metadata is malformed.")
17
+ return data
18
+
19
+ def _android_toolchain(source):
20
+ wrapper = source / ("gradlew.bat" if os.name == "nt" else "gradlew")
21
+ gradle = str(wrapper) if wrapper.is_file() else shutil.which("gradle")
22
+ sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
23
+ java = shutil.which("java")
24
+ missing = []
25
+ if not gradle: missing.append("Gradle executable or Gradle wrapper")
26
+ if not java: missing.append("Java runtime (JDK 17 recommended for Android Gradle Plugin 8.7.3)")
27
+ if not sdk or not Path(sdk).expanduser().is_dir(): missing.append("Android SDK configured through ANDROID_HOME or ANDROID_SDK_ROOT")
28
+ if missing: return None, "Missing Android build requirements: " + "; ".join(missing) + ". See docs/GOOGLE_COLAB_ANDROID.md."
29
+ return gradle, None
30
+
31
+ def build_project(project, force=False, android_artifacts="both"):
32
+ if android_artifacts not in {"apk", "aab", "both", "none"}: raise BuildError("android_artifacts must be apk, aab, both, or none")
33
+ source = find_project(project.directory)
34
+ delivery = project.delivery_directory
35
+ expected_parent = project.root / "deliverables"
36
+ if delivery.parent != expected_parent or delivery == source: raise ProjectPathError("Unsafe delivery path refused.")
37
+ if delivery.exists() or delivery.is_symlink():
38
+ if not force: raise ProjectPathError(f"Delivery folder exists: {delivery}. Use --force to replace it.")
39
+ if delivery.is_symlink() or not delivery.is_dir(): raise ProjectPathError("Refusing to replace a non-directory or symlink delivery path.")
40
+ shutil.rmtree(delivery)
41
+ delivery.mkdir(parents=True)
42
+ snapshot = delivery / "source" / project.name
43
+ snapshot.parent.mkdir(parents=True, exist_ok=True)
44
+ shutil.copytree(source, snapshot, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache", "*.pyc", "data.sqlite3", "build", ".gradle", ".venv", "venv"))
45
+ artifacts = delivery / "artifacts"; artifacts.mkdir()
46
+ logs = delivery / "logs"; logs.mkdir()
47
+ android = {"status": "not_requested", "requested": android_artifacts, "apk": [], "aab": [], "message": "Android target not selected."}
48
+ if "android" in project.targets and android_artifacts != "none":
49
+ gradle, reason = _android_toolchain(source)
50
+ if reason:
51
+ android = {"status": "blocked", "requested": android_artifacts, "apk": [], "aab": [], "message": reason}
52
+ else:
53
+ tasks = []
54
+ if android_artifacts in ("apk", "both"): tasks.append(":app:assembleDebug")
55
+ if android_artifacts in ("aab", "both"): tasks.append(":app:bundleRelease")
56
+ cmd = [gradle, "--no-daemon", *tasks]
57
+ try:
58
+ result = subprocess.run(cmd, cwd=source, capture_output=True, text=True, timeout=1800, check=False)
59
+ (logs / "android-build.stdout.log").write_text(result.stdout or "", encoding="utf-8")
60
+ (logs / "android-build.stderr.log").write_text(result.stderr or "", encoding="utf-8")
61
+ apk_paths = sorted((source / "app/build/outputs/apk").rglob("*.apk")) if (source / "app/build/outputs/apk").exists() and android_artifacts in ("apk", "both") else []
62
+ aab_paths = sorted((source / "app/build/outputs/bundle").rglob("*.aab")) if (source / "app/build/outputs/bundle").exists() and android_artifacts in ("aab", "both") else []
63
+ copied_apk, copied_aab = [], []
64
+ for paths, target in ((apk_paths, copied_apk), (aab_paths, copied_aab)):
65
+ for i, item in enumerate(paths):
66
+ filename = item.name if not (artifacts / item.name).exists() else f"{item.stem}-{i+1}{item.suffix}"
67
+ shutil.copy2(item, artifacts / filename); target.append(filename)
68
+ expected = bool(copied_apk) if android_artifacts == "apk" else bool(copied_aab) if android_artifacts == "aab" else bool(copied_apk) and bool(copied_aab)
69
+ okay = result.returncode == 0 and expected
70
+ android = {"status": "built" if okay else "failed", "requested": android_artifacts, "command": cmd, "returncode": result.returncode,
71
+ "apk": copied_apk, "aab": copied_aab, "stdout_tail": (result.stdout or "")[-4000:], "stderr_tail": (result.stderr or "")[-4000:],
72
+ "message": "Requested artifact checks passed." if okay else "Build command failed or requested artifacts were not produced. Review logs/."}
73
+ except subprocess.TimeoutExpired as exc:
74
+ android = {"status": "failed", "requested": android_artifacts, "apk": [], "aab": [], "message": "Android build timed out after 30 minutes.", "stdout_tail": str(exc.stdout or "")[-4000:], "stderr_tail": str(exc.stderr or "")[-4000:]}
75
+ except OSError as exc:
76
+ android = {"status": "failed", "requested": android_artifacts, "apk": [], "aab": [], "message": f"Could not start Android build: {exc}"}
77
+ elif "android" in project.targets:
78
+ android = {"status": "skipped", "requested": android_artifacts, "apk": [], "aab": [], "message": "Android build explicitly skipped."}
79
+ report = {"schema_version": 1, "product": "AssayFlux", "version": __version__, "project": project.name,
80
+ "created_at_epoch": int(time.time()), "source_directory": str(source), "delivery_directory": str(delivery),
81
+ "targets": project.targets, "status": "source_packaged", "checks": {"metadata": True, "source_snapshot": True},
82
+ "android_build": android, "limitations": ["Non-Android targets are packaged as source; use the corresponding run command or language-specific toolchain to compile/test them.",
83
+ "Release AAB signing is not configured automatically. Review app identity, permissions, privacy policy, and store requirements before release."]}
84
+ if android["status"] in ("failed", "blocked"): report["status"] = "partial"
85
+ (delivery / "BUILD_REPORT.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
86
+ (delivery / "DELIVERY_README.md").write_text(f"# {project.name} delivery\n\n`source/{project.name}/` is the source snapshot. `artifacts/` contains produced binaries. `logs/` contains build logs. Check `BUILD_REPORT.json`; a `partial` status means requested Android build did not complete.\n", encoding="utf-8")
87
+ return type("BuildResult", (), {"delivery_dir": delivery, "report": report, "success": report["status"] == "source_packaged"})()
88
+
89
+ def run_tests(project_dir):
90
+ project_dir = find_project(project_dir); tests = project_dir / "tests"
91
+ if not tests.is_dir(): return {"status": "no_tests", "returncode": 0, "stdout": "No tests directory.", "stderr": ""}
92
+ try: result = subprocess.run([sys.executable, "-m", "pytest", "-q", str(tests)], cwd=project_dir, capture_output=True, text=True, timeout=180, check=False)
93
+ except (OSError, subprocess.TimeoutExpired) as exc: return {"status": "failed", "returncode": 1, "stdout": "", "stderr": str(exc)}
94
+ return {"status": "passed" if result.returncode == 0 else "failed", "returncode": result.returncode, "stdout": result.stdout, "stderr": result.stderr}
95
+
96
+ def run_target(project_dir, target):
97
+ project_dir = find_project(project_dir)
98
+ commands = {"backend": [sys.executable, "backend_app.py"], "python": [sys.executable, "app.py"], "cli": [sys.executable, "app.py"], "javascript": ["node", "src/main.js"], "php": ["php", "src/main.php"], "bash": ["bash", "src/main.sh"]}
99
+ if target in ("website", "html"):
100
+ web = project_dir / "web"
101
+ if not web.is_dir(): raise BuildError("No web/ folder in this project.")
102
+ return subprocess.Popen([sys.executable, "-m", "http.server", "8000", "--directory", str(web)], cwd=project_dir)
103
+ if target in ("c", "cpp"):
104
+ compiler = shutil.which("clang" if target == "c" else "clang++") or shutil.which("gcc" if target == "c" else "g++")
105
+ if not compiler: raise BuildError(f"No {target.upper()} compiler found; install clang or gcc/g++.")
106
+ source = project_dir / ("src/main.c" if target == "c" else "src/main.cpp")
107
+ if not source.is_file(): raise BuildError(f"Expected source file missing: {source}")
108
+ binary = project_dir / "build" / ("main.exe" if os.name == "nt" else "main"); binary.parent.mkdir(parents=True, exist_ok=True)
109
+ result = subprocess.run([compiler, str(source), "-o", str(binary)], cwd=project_dir, capture_output=True, text=True, timeout=120, check=False)
110
+ if result.returncode: raise BuildError(result.stderr or "Compilation failed.")
111
+ return subprocess.run([str(binary)], cwd=project_dir, capture_output=True, text=True, timeout=30, check=False)
112
+ if target == "java":
113
+ javac, java = shutil.which("javac"), shutil.which("java")
114
+ if not javac or not java: raise BuildError("JDK not found; install a compatible JDK.")
115
+ source=project_dir/"src/Main.java"; out=project_dir/"build/java"; out.mkdir(parents=True,exist_ok=True)
116
+ result=subprocess.run([javac,"-d",str(out),str(source)],cwd=project_dir,capture_output=True,text=True,timeout=120,check=False)
117
+ if result.returncode: raise BuildError(result.stderr or "Java compilation failed.")
118
+ return subprocess.run([java,"-cp",str(out),"Main"],cwd=project_dir,capture_output=True,text=True,timeout=30,check=False)
119
+ if target == "kotlin":
120
+ compiler, java=shutil.which("kotlinc"),shutil.which("java")
121
+ if not compiler or not java: raise BuildError("Kotlin compiler and Java runtime are required.")
122
+ out=project_dir/"build/main.jar"; out.parent.mkdir(parents=True,exist_ok=True)
123
+ result=subprocess.run([compiler,str(project_dir/"src/main.kt"),"-include-runtime","-d",str(out)],cwd=project_dir,capture_output=True,text=True,timeout=180,check=False)
124
+ if result.returncode: raise BuildError(result.stderr or "Kotlin compilation failed.")
125
+ return subprocess.run([java,"-jar",str(out)],cwd=project_dir,capture_output=True,text=True,timeout=30,check=False)
126
+ if target == "android": raise BuildError("Android is built with `assayflux build --android-artifacts apk|aab|both`, not `run`.")
127
+ if target not in commands: raise BuildError(f"Run target {target!r} is not supported.")
128
+ exe=shutil.which(commands[target][0])
129
+ if not exe: raise BuildError(f"Required tool {commands[target][0]!r} is not installed.")
130
+ path = project_dir / commands[target][1] if len(commands[target]) > 1 else None
131
+ if path is not None and not path.is_file(): raise BuildError(f"Expected source file missing: {path}")
132
+ return subprocess.run([exe,*commands[target][1:]],cwd=project_dir,capture_output=True,text=True,timeout=120,check=False)
@@ -0,0 +1,67 @@
1
+ import argparse, json, sys
2
+ from . import __version__, VALID_TARGETS
3
+ from .project import Project
4
+ from .errors import AssayFluxError
5
+ from .generator import generate_project
6
+ from .builder import find_project, load_metadata, build_project, run_tests, run_target
7
+ from .planner import create_plan
8
+ from .relay import Relay
9
+ from .doctor import diagnostics
10
+
11
+ def project_from_dir(path):
12
+ directory = find_project(path); meta = load_metadata(directory)
13
+ return Project.create(meta["name"], root=directory.parent, targets=meta.get("targets", ["website"]), features=meta.get("features", []))
14
+
15
+ def main(argv=None):
16
+ parser = argparse.ArgumentParser(prog="assayflux", description="AssayFlux multi-language project toolkit")
17
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
18
+ subs = parser.add_subparsers(dest="command", required=True)
19
+ subs.add_parser("info", help="Show product and version information")
20
+ subs.add_parser("engines", help="List engines and tool availability")
21
+ doctor = subs.add_parser("doctor", help="Check local tools"); doctor.add_argument("--json", action="store_true", dest="as_json")
22
+ new = subs.add_parser("new", help="Generate a project workspace"); new.add_argument("name"); new.add_argument("--root", default=".")
23
+ new.add_argument("--target", action="append", choices=sorted(VALID_TARGETS)); new.add_argument("--feature", action="append", default=[])
24
+ for name in ("plan", "build", "test"):
25
+ cmd = subs.add_parser(name); cmd.add_argument("project_dir", nargs="?", default=".")
26
+ if name == "build":
27
+ cmd.add_argument("--force", action="store_true", help="Replace existing delivery folder")
28
+ cmd.add_argument("--android-artifacts", choices=("apk", "aab", "both", "none"), default="both", help="Choose Android outputs")
29
+ run = subs.add_parser("run", help="Run a generated project target"); run.add_argument("--project-dir", default=".")
30
+ run.add_argument("--target", required=True, choices=sorted(VALID_TARGETS - {"css"}))
31
+ args = parser.parse_args(argv)
32
+ try:
33
+ if args.command == "info": print(f"AssayFlux {__version__}\nPython-driven multi-language project generation, testing, and build orchestration.")
34
+ elif args.command == "engines": print(json.dumps(Relay().engines_report(), indent=2))
35
+ elif args.command == "doctor":
36
+ rows = diagnostics()
37
+ if args.as_json: print(json.dumps(rows, indent=2))
38
+ else:
39
+ for row in rows: print(f"{'OK' if row['available'] else '--'} {row['tool']}: {row['path']}")
40
+ elif args.command == "new":
41
+ project = Project.create(args.name, root=args.root, targets=args.target or ["website"], features=args.feature)
42
+ print(f"Created project: {generate_project(project)}"); print(f"Delivery folder: {project.delivery_directory}")
43
+ elif args.command == "plan": print(json.dumps(create_plan(project_from_dir(args.project_dir)), indent=2))
44
+ elif args.command == "build":
45
+ result = build_project(project_from_dir(args.project_dir), force=args.force, android_artifacts=args.android_artifacts)
46
+ print(f"Delivery package: {result.delivery_dir}"); print(f"Report: {result.delivery_dir/'BUILD_REPORT.json'}"); print(f"Build status: {result.report['status']}")
47
+ if not result.success: print("Warning: requested build did not fully succeed; inspect BUILD_REPORT.json and logs/.", file=sys.stderr); return 1
48
+ elif args.command == "test":
49
+ result = run_tests(args.project_dir)
50
+ if result["stdout"]: print(result["stdout"], end="")
51
+ if result["stderr"]: print(result["stderr"], file=sys.stderr, end="")
52
+ print(f"Test status: {result['status']}"); return result["returncode"]
53
+ elif args.command == "run":
54
+ result = run_target(args.project_dir, args.target)
55
+ if hasattr(result, "poll"):
56
+ print(f"Started {args.target}. Stop with Ctrl+C.")
57
+ try: result.wait()
58
+ except KeyboardInterrupt: result.terminate(); return 130
59
+ else:
60
+ if result.stdout: print(result.stdout, end="")
61
+ if result.stderr: print(result.stderr, file=sys.stderr, end="")
62
+ return result.returncode
63
+ return 0
64
+ except (AssayFluxError, OSError, ValueError, TimeoutError, json.JSONDecodeError) as exc:
65
+ print(f"assayflux: error: {exc}", file=sys.stderr); return 2
66
+
67
+ if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ import os, shutil, sys
2
+ from pathlib import Path
3
+
4
+ TOOLS = {
5
+ "C compiler": ("clang", "gcc"), "C++ compiler": ("clang++", "g++"),
6
+ "Node.js": ("node", "nodejs"), "Java compiler": ("javac",), "Java runtime": ("java",),
7
+ "Kotlin compiler": ("kotlinc",), "PHP": ("php",), "Bash": ("bash",), "Gradle": ("gradle",),
8
+ "Android SDK manager": ("sdkmanager",),
9
+ }
10
+ def diagnostics():
11
+ rows = [{"tool": "Python", "path": sys.executable, "available": True}]
12
+ for label, names in TOOLS.items():
13
+ path = next((shutil.which(n) for n in names if shutil.which(n)), None)
14
+ rows.append({"tool": label, "path": path or "not found", "available": bool(path)})
15
+ sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
16
+ sdk_path = Path(sdk).expanduser() if sdk else None
17
+ rows.append({"tool": "Android SDK", "path": str(sdk_path) if sdk_path and sdk_path.is_dir() else "ANDROID_HOME/ANDROID_SDK_ROOT not configured or path missing", "available": bool(sdk_path and sdk_path.is_dir())})
18
+ return rows
@@ -0,0 +1,17 @@
1
+ from .base import Engine
2
+
3
+ ENGINE_REGISTRY = {
4
+ "python": Engine("python", "Python", "Generate and run Python", ("python", "python3")),
5
+ "c": Engine("c", "C", "Compile C sources", ("clang", "gcc")),
6
+ "cpp": Engine("cpp", "C++", "Compile C++ sources", ("clang++", "g++")),
7
+ "html": Engine("html", "HTML", "Generate static website files"),
8
+ "css": Engine("css", "CSS", "Generate stylesheets"),
9
+ "javascript": Engine("javascript", "JavaScript", "Generate/run JavaScript", ("node", "nodejs")),
10
+ "java": Engine("java", "Java", "Compile/run Java", ("javac", "java")),
11
+ "kotlin": Engine("kotlin", "Kotlin", "Compile/run Kotlin", ("kotlinc", "java")),
12
+ "php": Engine("php", "PHP", "Run PHP scripts", ("php",)),
13
+ "bash": Engine("bash", "Bash", "Run shell scripts", ("bash",)),
14
+ "android": Engine("android", "Android", "Generate Android project and orchestrate builds", ("gradle",)),
15
+ "android-apk": Engine("android-apk", "Android APK", "Build Android debug APK", ("gradle",)),
16
+ "android-aab": Engine("android-aab", "Android App Bundle", "Build Android release AAB", ("gradle",)),
17
+ }
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+ import shutil
3
+ import subprocess
4
+
5
+ @dataclass(frozen=True)
6
+ class Engine:
7
+ name: str
8
+ language: str
9
+ purpose: str
10
+ tools: tuple = ()
11
+
12
+ def executable(self):
13
+ for name in self.tools:
14
+ found = shutil.which(name)
15
+ if found:
16
+ return found
17
+ return None
18
+
19
+ def describe(self):
20
+ exe = self.executable()
21
+ return {"name": self.name, "language": self.language, "purpose": self.purpose,
22
+ "tools": list(self.tools), "available": bool(exe) if self.tools else True,
23
+ "executable": exe}
24
+
25
+ def run(self, args, cwd, timeout=30):
26
+ return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=False)
@@ -0,0 +1,11 @@
1
+ class AssayFluxError(Exception):
2
+ """Base error."""
3
+
4
+ class ProjectNameError(AssayFluxError):
5
+ """Invalid project name."""
6
+
7
+ class ProjectPathError(AssayFluxError):
8
+ """Invalid project path or destination."""
9
+
10
+ class BuildError(AssayFluxError):
11
+ """Build/test/run operation failed."""
@@ -0,0 +1,173 @@
1
+ import json
2
+ import re
3
+ import shutil
4
+ from pathlib import Path
5
+ from .errors import ProjectPathError
6
+ from .planner import create_plan
7
+
8
+ def write_file(root, relative, content):
9
+ path = root / relative
10
+ path.parent.mkdir(parents=True, exist_ok=True)
11
+ path.write_text(content.strip() + "\n", encoding="utf-8")
12
+
13
+ def generate_project(project):
14
+ dest = project.directory
15
+ if dest.exists():
16
+ raise ProjectPathError(f"Project already exists: {dest}. Choose another name or move it.")
17
+ dest.parent.mkdir(parents=True, exist_ok=True)
18
+ dest.mkdir()
19
+ try:
20
+ meta = {"schema_version": 1, "product": "AssayFlux", **project.describe(), "status": "generated"}
21
+ write_file(dest, ".assayflux/project.json", json.dumps(meta, indent=2))
22
+ write_file(dest, ".assayflux/plan.json", json.dumps(create_plan(project), indent=2))
23
+ write_file(dest, ".gitignore", "__pycache__/\n*.py[cod]\n.env\n.venv/\ndata.sqlite3\nbuild/\n.gradle/\n")
24
+ write_file(dest, "README.md", f"""# {project.name}
25
+
26
+ Generated by AssayFlux.
27
+
28
+ Targets: {', '.join(project.targets)}
29
+ Features: {', '.join(project.features) or 'None specified'}
30
+
31
+ Run `assayflux plan`, `assayflux build`, and `assayflux test` from this directory.
32
+ Build output is saved separately under `../deliverables/{project.name}/`.
33
+ """)
34
+ if "website" in project.targets or "html" in project.targets:
35
+ write_file(dest, "web/index.html", f"""<!doctype html>
36
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
37
+ <title>{project.name}</title><link rel="stylesheet" href="style.css"></head>
38
+ <body><main class="card"><p class="brand">ASSAYFLUX</p><h1>{project.name}</h1>
39
+ <p>Your project workspace has been generated successfully.</p><button id="check">Test interface</button>
40
+ <p id="status" role="status">Ready.</p></main><script src="app.js"></script></body></html>""")
41
+ write_file(dest, "web/style.css", """
42
+ :root{font-family:system-ui,sans-serif;color:#172033;background:#f2f5fa}
43
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:1rem}
44
+ .card{width:min(100%,42rem);padding:clamp(1.5rem,5vw,3rem);background:#fff;border-radius:1.25rem;box-shadow:0 1rem 3rem #14243a18}
45
+ .brand{font-size:.75rem;letter-spacing:.13em;font-weight:700;color:#315da8}
46
+ h1{font-size:clamp(2rem,8vw,4rem);margin:.5rem 0}button{border:0;border-radius:.7rem;padding:.8rem 1rem;background:#234c9a;color:#fff;font:inherit}
47
+ """)
48
+ write_file(dest, "web/app.js", """
49
+ document.querySelector("#check")?.addEventListener("click",()=>{
50
+ document.querySelector("#status").textContent="Interface test passed.";
51
+ });
52
+ """)
53
+ if "css" in project.targets:
54
+ write_file(dest, "src/style.css", ":root{font-family:system-ui,sans-serif;color:#172033;background:#f2f5fa}\n*{box-sizing:border-box}\n")
55
+ if "backend" in project.targets:
56
+ write_file(dest, "backend_app.py", """
57
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
58
+ from pathlib import Path
59
+ import json, sqlite3
60
+
61
+ DB = Path(__file__).with_name("data.sqlite3")
62
+ def init_db():
63
+ with sqlite3.connect(DB) as con:
64
+ con.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP)")
65
+ class Handler(BaseHTTPRequestHandler):
66
+ def send_json(self, status, data):
67
+ body=json.dumps(data).encode()
68
+ self.send_response(status); self.send_header("Content-Type","application/json; charset=utf-8")
69
+ self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body)
70
+ def do_GET(self):
71
+ if self.path == "/api/health":
72
+ self.send_json(200, {"ok":True,"service":"AssayFlux starter backend"})
73
+ elif self.path == "/api/notes":
74
+ with sqlite3.connect(DB) as con:
75
+ rows=con.execute("SELECT id,text,created_at FROM notes ORDER BY id DESC").fetchall()
76
+ self.send_json(200, {"items":[{"id":r[0],"text":r[1],"created_at":r[2]} for r in rows]})
77
+ else: self.send_json(404, {"error":"not_found"})
78
+ def do_POST(self):
79
+ if self.path != "/api/notes":
80
+ self.send_json(404, {"error":"not_found"}); return
81
+ try:
82
+ size=int(self.headers.get("Content-Length","0"))
83
+ if size < 1 or size > 65536: raise ValueError("invalid body size")
84
+ data=json.loads(self.rfile.read(size))
85
+ text=data.get("text") if isinstance(data,dict) else None
86
+ if not isinstance(text,str) or not text.strip() or len(text)>2000: raise ValueError("text must be 1-2000 characters")
87
+ with sqlite3.connect(DB) as con:
88
+ cur=con.execute("INSERT INTO notes(text) VALUES (?)",(text.strip(),))
89
+ record_id=cur.lastrowid
90
+ self.send_json(201, {"id":record_id,"text":text.strip()})
91
+ except (ValueError, json.JSONDecodeError) as exc:
92
+ self.send_json(400, {"error":str(exc)})
93
+ def log_message(self, fmt, *args): print("%s - %s"%(self.address_string(),fmt%args))
94
+ if __name__=="__main__":
95
+ init_db(); server=ThreadingHTTPServer(("127.0.0.1",8080),Handler)
96
+ print("Backend running at http://127.0.0.1:8080/api/health")
97
+ try: server.serve_forever()
98
+ except KeyboardInterrupt: pass
99
+ finally: server.server_close()
100
+ """)
101
+ if "cli" in project.targets or "python" in project.targets:
102
+ write_file(dest, "app.py", """
103
+ def main():
104
+ print("Application initialized successfully.")
105
+ if __name__ == "__main__":
106
+ main()
107
+ """)
108
+ if "c" in project.targets:
109
+ write_file(dest, "src/main.c", '#include <stdio.h>\nint main(void) { puts("Hello from C"); return 0; }')
110
+ if "cpp" in project.targets:
111
+ write_file(dest, "src/main.cpp", '#include <iostream>\nint main() { std::cout << "Hello from C++\\n"; return 0; }')
112
+ if "javascript" in project.targets:
113
+ write_file(dest, "src/main.js", 'console.log("Hello from JavaScript");')
114
+ if "java" in project.targets:
115
+ write_file(dest, "src/Main.java", 'public class Main { public static void main(String[] args) { System.out.println("Hello from Java"); } }')
116
+ if "kotlin" in project.targets:
117
+ write_file(dest, "src/main.kt", 'fun main() { println("Hello from Kotlin") }')
118
+ if "php" in project.targets:
119
+ write_file(dest, "src/main.php", '<?php echo "Hello from PHP" . PHP_EOL;')
120
+ if "bash" in project.targets:
121
+ write_file(dest, "src/main.sh", '#!/usr/bin/env bash\nset -euo pipefail\necho "Hello from Bash"')
122
+ if "android" in project.targets:
123
+ package_suffix = re.sub(r"[^a-z0-9]", "", project.name.lower())
124
+ package_id = "org.assayflux." + (package_suffix or "app")
125
+ write_file(dest, "settings.gradle.kts", f'''pluginManagement {{ repositories {{ google(); mavenCentral(); gradlePluginPortal() }} }}
126
+ dependencyResolutionManagement {{ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories {{ google(); mavenCentral() }} }}
127
+ rootProject.name = "{project.name}"
128
+ include(":app")''')
129
+ write_file(dest, "build.gradle.kts", 'plugins { id("com.android.application") version "8.7.3" apply false }')
130
+ write_file(dest, "gradle.properties", "org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\nandroid.useAndroidX=true\n")
131
+ write_file(dest, "app/build.gradle.kts", f'''plugins {{ id("com.android.application") }}
132
+
133
+ android {{
134
+ namespace = "{package_id}"
135
+ compileSdk = 35
136
+ defaultConfig {{ applicationId = "{package_id}"; minSdk = 23; targetSdk = 35; versionCode = 1; versionName = "1.0.0" }}
137
+ buildTypes {{ release {{ isMinifyEnabled = false }} }}
138
+ }}
139
+
140
+ // Release signing can be configured here using environment-backed credentials.''')
141
+ write_file(dest, "app/src/main/AndroidManifest.xml", f'''<?xml version="1.0" encoding="utf-8"?>
142
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"><application android:label="{project.name}" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true"><activity android:name="{package_id}.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity></application></manifest>''')
143
+ write_file(dest, "app/src/main/java/" + package_id.replace(".", "/") + "/MainActivity.kt", f'''package {package_id}
144
+
145
+ import android.app.Activity
146
+ import android.os.Bundle
147
+ import android.graphics.Color
148
+ import android.view.Gravity
149
+ import android.widget.TextView
150
+ import android.widget.LinearLayout
151
+
152
+ class MainActivity : Activity() {{
153
+ override fun onCreate(savedInstanceState: Bundle?) {{
154
+ super.onCreate(savedInstanceState)
155
+ val layout = LinearLayout(this).apply {{ orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER; setPadding(32, 32, 32, 32); setBackgroundColor(Color.rgb(244,247,252)) }}
156
+ val title = TextView(this).apply {{ text = "{project.name}"; textSize = 30f; setTextColor(Color.rgb(25,45,80)); gravity = Gravity.CENTER }}
157
+ val detail = TextView(this).apply {{ text = "Built with AssayFlux"; textSize = 16f; gravity = Gravity.CENTER; setPadding(0,24,0,0) }}
158
+ layout.addView(title); layout.addView(detail); setContentView(layout)
159
+ }}
160
+ }}''')
161
+ write_file(dest, "ANDROID_BUILD.md", """# Android build instructions\n\nThis target generates a native Android Gradle project. Building requires a compatible JDK, Android SDK (platform 35 and build-tools), and Gradle 8.9+ available on PATH.\n\n- Debug APK: `gradle --no-daemon :app:assembleDebug`\n- Release AAB: `gradle --no-daemon :app:bundleRelease`\n\nThe generated release bundle is not guaranteed to be signed. Configure release signing securely before publishing to Google Play. Test the output on a real device and verify application ID, permissions, privacy policy, and Play requirements.\n""")
162
+ write_file(dest, "tests/test_generated_project.py", """
163
+ from pathlib import Path
164
+ def test_metadata_present():
165
+ root=Path(__file__).parents[1]
166
+ assert (root/".assayflux"/"project.json").is_file()
167
+ def test_readme_present():
168
+ assert (Path(__file__).parents[1]/"README.md").is_file()
169
+ """)
170
+ except Exception:
171
+ shutil.rmtree(dest, ignore_errors=True)
172
+ raise
173
+ return dest
@@ -0,0 +1,8 @@
1
+ def create_plan(project):
2
+ tasks = [{"id": "validate_spec", "engine": "core", "depends_on": []},
3
+ {"id": "write_metadata", "engine": "core", "depends_on": ["validate_spec"]}]
4
+ for target in project.targets:
5
+ engine = {"website": "html", "backend": "python", "cli": "python"}.get(target, target)
6
+ tasks.append({"id": f"generate_{target}", "engine": engine, "depends_on": ["write_metadata"]})
7
+ tasks.append({"id": "package_delivery", "engine": "core", "depends_on": [t["id"] for t in tasks]})
8
+ return {"schema_version": 1, "project": project.name, "tasks": tasks}
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+ import re
3
+ from pathlib import Path
4
+ from .errors import AssayFluxError, ProjectNameError
5
+
6
+ VALID_TARGETS = {"website", "html", "css", "backend", "cli", "python", "c", "cpp", "javascript", "java", "kotlin", "php", "bash", "android"}
7
+
8
+ def validate_name(name: str) -> str:
9
+ if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", name):
10
+ raise ProjectNameError("Name must start with a letter and contain only letters, digits, '_' or '-' (max 64 characters).")
11
+ return name
12
+
13
+ class Project:
14
+ def __init__(self, name, root=".", targets=None, features=None):
15
+ self.name = validate_name(name)
16
+ self.root = Path(root).expanduser().resolve()
17
+ raw_targets = ["website"] if targets is None else list(targets)
18
+ if not raw_targets: raise AssayFluxError("Select at least one target.")
19
+ if any(not isinstance(t, str) for t in raw_targets): raise AssayFluxError("Targets must be strings.")
20
+ self.targets = list(dict.fromkeys(raw_targets))
21
+ self.features = list(dict.fromkeys(features or []))
22
+ unknown = set(self.targets) - VALID_TARGETS
23
+ if unknown: raise AssayFluxError("Unsupported target(s): " + ", ".join(sorted(unknown)))
24
+ if any(not isinstance(x, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", x) for x in self.features):
25
+ raise AssayFluxError("Features must be 1-64 characters using letters, digits, '.', '_' or '-'.")
26
+
27
+ @classmethod
28
+ def create(cls, name, root=".", targets=None, features=None): return cls(name, root, targets, features)
29
+ @property
30
+ def directory(self): return self.root / self.name
31
+ @property
32
+ def delivery_directory(self): return self.root / "deliverables" / self.name
33
+ def describe(self):
34
+ return {"name": self.name, "root": str(self.root), "directory": str(self.directory), "delivery_directory": str(self.delivery_directory), "targets": self.targets, "features": self.features}
35
+ def generate(self):
36
+ from .generator import generate_project
37
+ return generate_project(self)
38
+ def plan(self):
39
+ from .planner import create_plan
40
+ return create_plan(self)
41
+ def build(self, force=False, android_artifacts="both"):
42
+ from .builder import build_project
43
+ return build_project(self, force=force, android_artifacts=android_artifacts)
@@ -0,0 +1,26 @@
1
+ from .engines import ENGINE_REGISTRY
2
+ from .errors import BuildError
3
+
4
+ class Relay:
5
+ """Capability-aware task router. Actual build steps are dispatched by the builder."""
6
+ def __init__(self):
7
+ self.engines = ENGINE_REGISTRY
8
+
9
+ def engines_report(self):
10
+ return [engine.describe() for engine in self.engines.values()]
11
+
12
+ def route(self, task):
13
+ engine_name = task.get("engine")
14
+ if engine_name == "core":
15
+ return None
16
+ engine = self.engines.get(engine_name)
17
+ if engine is None:
18
+ raise BuildError(f"No engine registered for {engine_name!r}")
19
+ return engine
20
+
21
+ def dispatch(self, task, context):
22
+ engine = self.route(task)
23
+ if engine is None:
24
+ return {"task": task["id"], "status": "handled_by_core"}
25
+ return {"task": task["id"], "status": "routed", "engine": engine.name,
26
+ "available": engine.describe()["available"]}
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: assayflux
3
+ Version: 0.4.0
4
+ Summary: Python-driven multi-language project generation, testing, and build orchestration
5
+ Author: AssayFlux contributors
6
+ License-Expression: MIT
7
+ Keywords: build-tool,project-generator,android,apk,aab,multi-language
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0; extra == "dev"
25
+ Requires-Dist: build>=1.2; extra == "dev"
26
+ Requires-Dist: twine>=5.1; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # AssayFlux
30
+
31
+ **AssayFlux is a Python-based multi-language project generator and build orchestrator.** It creates starter workspaces, plans tasks, detects local toolchains, runs supported project targets, packages source into a separate delivery folder, and can orchestrate Android debug APK and release AAB builds when a compatible toolchain is configured.
32
+
33
+ AssayFlux is a project toolkit, not a new programming language and not a transpiler: it does not translate arbitrary source code between languages.
34
+
35
+ ## Requirements
36
+
37
+ - Python 3.10 or newer
38
+ - Optional per-target toolchains: Node.js, C/C++ compiler, JDK, Kotlin compiler, PHP, Bash
39
+ - Android builds: JDK 17, Gradle 8.9 compatible with Android Gradle Plugin 8.7.3, Android SDK platform 35 and build-tools 35.0.0, and `ANDROID_HOME` or `ANDROID_SDK_ROOT`
40
+
41
+ ## Install for development
42
+
43
+ ```bash
44
+ python -m pip install -e '.[dev]'
45
+ python -m pytest -q
46
+ assayflux --version
47
+ assayflux info
48
+ assayflux engines
49
+ assayflux doctor
50
+ ```
51
+
52
+ ## Quick start
53
+
54
+ ```bash
55
+ assayflux new ResearchSite --target website --target backend --feature offline_database
56
+ cd ResearchSite
57
+ assayflux plan
58
+ assayflux test
59
+ assayflux build
60
+ assayflux run --target backend
61
+ ```
62
+
63
+ Open `http://127.0.0.1:8080/api/health` for the generated local backend while it is running. The starter backend binds to loopback only. The website can be served using `assayflux run --target website` and then opened at `http://127.0.0.1:8000`.
64
+
65
+ ## Supported targets
66
+
67
+ `website`, `html`, `css`, `backend`, `cli`, `python`, `c`, `cpp`, `javascript`, `java`, `kotlin`, `php`, `bash`, `android`.
68
+
69
+ ```bash
70
+ assayflux new NativeDemo --target android
71
+ cd NativeDemo
72
+ assayflux build --android-artifacts apk # debug APK
73
+ assayflux build --android-artifacts aab # release AAB
74
+ assayflux build --android-artifacts both # both outputs
75
+ ```
76
+
77
+ APK and AAB are separate build tasks and can be requested independently. Android build failures or missing tools are recorded honestly in `BUILD_REPORT.json`; a source delivery is not described as a successful Android build when binaries are missing. Read [docs/GOOGLE_COLAB_ANDROID.md](docs/GOOGLE_COLAB_ANDROID.md) to test builds in Google Colab.
78
+
79
+ ## Delivery structure
80
+
81
+ ```text
82
+ workspace/
83
+ ResearchSite/ # Editable project
84
+ .assayflux/project.json
85
+ .assayflux/plan.json
86
+ web/ # if website target selected
87
+ backend_app.py # if backend target selected
88
+ tests/
89
+ deliverables/ResearchSite/
90
+ source/ # Source snapshot
91
+ artifacts/ # Produced APK/AAB or other build artifacts
92
+ logs/ # Android build stdout/stderr
93
+ BUILD_REPORT.json # Machine-readable status
94
+ DELIVERY_README.md
95
+ ```
96
+
97
+ `assayflux build` will not overwrite an existing delivery folder unless `--force` is explicitly passed. `--force` only replaces the delivery folder, not the editable source workspace.
98
+
99
+ ## Commands
100
+
101
+ - `assayflux info`, `assayflux --version`
102
+ - `assayflux engines` — engine capabilities/tool detection
103
+ - `assayflux doctor [--json]` — toolchain diagnostics
104
+ - `assayflux new NAME [--root DIR] [--target TARGET] [--feature FEATURE]`
105
+ - `assayflux plan [PROJECT_DIR]`
106
+ - `assayflux test [PROJECT_DIR]`
107
+ - `assayflux build [PROJECT_DIR] [--force] [--android-artifacts apk|aab|both|none]`
108
+ - `assayflux run --project-dir DIR --target TARGET`
109
+
110
+ ## Python API
111
+
112
+ ```python
113
+ from assayflux import Project
114
+
115
+ project = Project.create("ResearchPortal", root=".", targets=["website", "backend"], features=["offline_database", "data_analysis"])
116
+ project.generate()
117
+ result = project.build()
118
+ print(result.delivery_dir)
119
+ ```
120
+
121
+ ## Security and limitations
122
+
123
+ - Build/test/run commands can execute generated or project-local code. Do not run untrusted projects; this is not a sandbox.
124
+ - Generated backend starter code is a minimal example, not a hardened internet-facing API. Keep it on loopback unless you add authentication, TLS, rate limits, and deployment hardening.
125
+ - Android release AAB signing is not configured automatically. Configure signing secrets securely and test on devices before store release.
126
+ - Non-Android `build` currently snapshots source and reports packaging; it does not claim to compile every language target. Use `run` and that target's toolchain to compile/run supported examples.
127
+ - The normalized PyPI endpoint for `assayflux` returned not-found during the preparation check, but PyPI name availability must still be confirmed while creating the project under your own account. Also, this package uses the Python import namespace `assayflux`; do not install it in the same environment as your older, separate AssayFlux interpreter package if that package also uses the same import namespace. Configure a Trusted Publisher or API token. A ZIP/build check cannot publish the package for you.
128
+
129
+ ## Release build
130
+
131
+ ```bash
132
+ python -m pip install --upgrade build twine
133
+ python -m pytest -q
134
+ python -m build
135
+ python -m twine check dist/*
136
+ ```
137
+
138
+ For GitHub Actions publishing, configure PyPI Trusted Publishing for the repository and workflow `.github/workflows/publish.yml`, then create a GitHub Release matching the version in `pyproject.toml`.
139
+
140
+ ## Google Colab release workflow
141
+
142
+ Use `AssayFlux_Colab_Full_Test_then_PyPI.ipynb` to test the source, build fresh distributions, validate the wheel and source archive, test the built wheel in isolation, and only then optionally publish to real PyPI. This workflow skips TestPyPI and requires an explicit production-publish confirmation.
@@ -0,0 +1,23 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/assayflux/__init__.py
5
+ src/assayflux/__main__.py
6
+ src/assayflux/builder.py
7
+ src/assayflux/cli.py
8
+ src/assayflux/doctor.py
9
+ src/assayflux/errors.py
10
+ src/assayflux/generator.py
11
+ src/assayflux/planner.py
12
+ src/assayflux/project.py
13
+ src/assayflux/relay.py
14
+ src/assayflux.egg-info/PKG-INFO
15
+ src/assayflux.egg-info/SOURCES.txt
16
+ src/assayflux.egg-info/dependency_links.txt
17
+ src/assayflux.egg-info/entry_points.txt
18
+ src/assayflux.egg-info/requires.txt
19
+ src/assayflux.egg-info/top_level.txt
20
+ src/assayflux/engines/__init__.py
21
+ src/assayflux/engines/base.py
22
+ tests/test_cli.py
23
+ tests/test_project.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ assayflux = assayflux.cli:main
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ pytest>=8.0
4
+ build>=1.2
5
+ twine>=5.1
@@ -0,0 +1 @@
1
+ assayflux
@@ -0,0 +1,18 @@
1
+ import json
2
+ from assayflux.cli import main
3
+
4
+ def test_cli_info(capsys):
5
+ assert main(["info"]) == 0
6
+ assert "AssayFlux 0.4.0" in capsys.readouterr().out
7
+
8
+ def test_cli_new_plan_build(tmp_path, capsys):
9
+ assert main(["new", "Demo", "--root", str(tmp_path), "--target", "website"]) == 0
10
+ assert main(["plan", str(tmp_path / "Demo")]) == 0
11
+ assert '"package_delivery"' in capsys.readouterr().out
12
+ assert main(["build", str(tmp_path / "Demo")]) == 0
13
+ assert (tmp_path / "deliverables/Demo/BUILD_REPORT.json").is_file()
14
+
15
+ def test_doctor_json(capsys):
16
+ assert main(["doctor", "--json"]) == 0
17
+ data = json.loads(capsys.readouterr().out)
18
+ assert data[0]["tool"] == "Python"
@@ -0,0 +1,102 @@
1
+ import json
2
+ import pytest
3
+ from assayflux import Project, __version__
4
+ from assayflux.errors import ProjectNameError, ProjectPathError, AssayFluxError, BuildError
5
+ from assayflux.generator import generate_project
6
+ from assayflux.builder import build_project, find_project, load_metadata
7
+ from assayflux.planner import create_plan
8
+ from assayflux.relay import Relay
9
+
10
+ def test_product_identity():
11
+ assert __version__ == "0.4.0"
12
+ assert Project.create("Demo").name == "Demo"
13
+
14
+ def test_project_name_validation():
15
+ with pytest.raises(ProjectNameError): Project.create("../bad")
16
+ with pytest.raises(ProjectNameError): Project.create("1starts-with-number")
17
+
18
+ def test_empty_and_unknown_targets_rejected(tmp_path):
19
+ with pytest.raises(AssayFluxError): Project.create("Demo", root=tmp_path, targets=[])
20
+ with pytest.raises(AssayFluxError): Project.create("Demo", root=tmp_path, targets=["unknown"])
21
+
22
+ def test_generates_website_and_backend(tmp_path):
23
+ p = Project.create("Demo", root=tmp_path, targets=["website", "backend"])
24
+ d = generate_project(p)
25
+ assert (d / "web/index.html").is_file()
26
+ assert (d / "backend_app.py").is_file()
27
+ meta = json.loads((d / ".assayflux/project.json").read_text())
28
+ assert meta["name"] == "Demo" and meta["product"] == "AssayFlux"
29
+ assert "ASSAYFLUX" in (d / "web/index.html").read_text()
30
+
31
+ def test_no_overwrite_project(tmp_path):
32
+ p = Project.create("Demo", root=tmp_path)
33
+ generate_project(p)
34
+ with pytest.raises(ProjectPathError): generate_project(p)
35
+
36
+ def test_delivery_is_separate_and_report_is_valid(tmp_path):
37
+ p = Project.create("Demo", root=tmp_path, targets=["website"])
38
+ generate_project(p)
39
+ result = build_project(p)
40
+ assert result.delivery_dir == tmp_path / "deliverables" / "Demo"
41
+ assert (result.delivery_dir / "source/Demo/web/index.html").is_file()
42
+ report = json.loads((result.delivery_dir / "BUILD_REPORT.json").read_text())
43
+ assert report["product"] == "AssayFlux" and report["status"] == "source_packaged"
44
+ with pytest.raises(ProjectPathError): build_project(p)
45
+
46
+ def test_force_replaces_only_delivery_folder(tmp_path):
47
+ p = Project.create("Demo", root=tmp_path)
48
+ generate_project(p); first = build_project(p)
49
+ marker = first.delivery_dir / "remove-me.txt"; marker.write_text("x")
50
+ second = build_project(p, force=True)
51
+ assert not (second.delivery_dir / "remove-me.txt").exists()
52
+ assert p.directory.exists()
53
+
54
+ def test_plan_dependencies_are_valid(tmp_path):
55
+ p = Project.create("Demo", root=tmp_path, targets=["website", "android"])
56
+ plan = create_plan(p); ids = {t["id"] for t in plan["tasks"]}
57
+ for task in plan["tasks"]:
58
+ assert set(task["depends_on"]) <= ids
59
+
60
+ def test_engine_registry_has_android_outputs():
61
+ names = {e["name"] for e in Relay().engines_report()}
62
+ assert {"python", "c", "cpp", "javascript", "java", "kotlin", "php", "bash", "android", "android-apk", "android-aab"} <= names
63
+
64
+ def test_android_project_package_id_handles_hyphen(tmp_path):
65
+ p = Project.create("Demo-App", root=tmp_path, targets=["android"])
66
+ d = generate_project(p)
67
+ assert (d / "app/src/main/java/org/assayflux/demoapp/MainActivity.kt").is_file()
68
+ assert 'applicationId = "org.assayflux.demoapp"' in (d / "app/build.gradle.kts").read_text()
69
+
70
+ def test_android_build_missing_toolchain_is_reported(tmp_path, monkeypatch):
71
+ monkeypatch.delenv("ANDROID_HOME", raising=False); monkeypatch.delenv("ANDROID_SDK_ROOT", raising=False)
72
+ monkeypatch.setattr("assayflux.builder.shutil.which", lambda name: None)
73
+ p = Project.create("AndroidDemo", root=tmp_path, targets=["android"])
74
+ generate_project(p)
75
+ result = build_project(p, android_artifacts="apk")
76
+ report = json.loads((result.delivery_dir / "BUILD_REPORT.json").read_text())
77
+ assert report["status"] == "partial"
78
+ assert report["android_build"]["status"] == "blocked"
79
+ assert "Gradle" in report["android_build"]["message"]
80
+
81
+ def test_delivery_symlink_is_not_followed_or_deleted(tmp_path):
82
+ p = Project.create("Demo", root=tmp_path); generate_project(p)
83
+ other = tmp_path / "deliverables" / "Other"
84
+ other.mkdir(parents=True); marker = other / "keep.txt"; marker.write_text("safe")
85
+ p.delivery_directory.parent.mkdir(parents=True, exist_ok=True)
86
+ try: p.delivery_directory.symlink_to(other, target_is_directory=True)
87
+ except OSError: pytest.skip("Symlinks unavailable on this platform")
88
+ with pytest.raises(ProjectPathError): build_project(p, force=True)
89
+ assert marker.read_text() == "safe"
90
+
91
+ def test_html_and_css_targets_generate_files(tmp_path):
92
+ p = Project.create("WebBits", root=tmp_path, targets=["html", "css"])
93
+ d = generate_project(p)
94
+ assert (d / "web/index.html").is_file()
95
+ assert (d / "src/style.css").is_file()
96
+
97
+ def test_android_artifact_argument_validated(tmp_path):
98
+ p = Project.create("Demo", root=tmp_path, targets=["website"]); generate_project(p)
99
+ with pytest.raises(BuildError): build_project(p, android_artifacts="bundle")
100
+
101
+ def test_load_metadata_rejects_non_project(tmp_path):
102
+ with pytest.raises(BuildError): find_project(tmp_path)