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.
- assayflux-0.4.0/LICENSE +17 -0
- assayflux-0.4.0/PKG-INFO +142 -0
- assayflux-0.4.0/README.md +114 -0
- assayflux-0.4.0/pyproject.toml +42 -0
- assayflux-0.4.0/setup.cfg +4 -0
- assayflux-0.4.0/src/assayflux/__init__.py +5 -0
- assayflux-0.4.0/src/assayflux/__main__.py +2 -0
- assayflux-0.4.0/src/assayflux/builder.py +132 -0
- assayflux-0.4.0/src/assayflux/cli.py +67 -0
- assayflux-0.4.0/src/assayflux/doctor.py +18 -0
- assayflux-0.4.0/src/assayflux/engines/__init__.py +17 -0
- assayflux-0.4.0/src/assayflux/engines/base.py +26 -0
- assayflux-0.4.0/src/assayflux/errors.py +11 -0
- assayflux-0.4.0/src/assayflux/generator.py +173 -0
- assayflux-0.4.0/src/assayflux/planner.py +8 -0
- assayflux-0.4.0/src/assayflux/project.py +43 -0
- assayflux-0.4.0/src/assayflux/relay.py +26 -0
- assayflux-0.4.0/src/assayflux.egg-info/PKG-INFO +142 -0
- assayflux-0.4.0/src/assayflux.egg-info/SOURCES.txt +23 -0
- assayflux-0.4.0/src/assayflux.egg-info/dependency_links.txt +1 -0
- assayflux-0.4.0/src/assayflux.egg-info/entry_points.txt +2 -0
- assayflux-0.4.0/src/assayflux.egg-info/requires.txt +5 -0
- assayflux-0.4.0/src/assayflux.egg-info/top_level.txt +1 -0
- assayflux-0.4.0/tests/test_cli.py +18 -0
- assayflux-0.4.0/tests/test_project.py +102 -0
assayflux-0.4.0/LICENSE
ADDED
|
@@ -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.
|
assayflux-0.4.0/PKG-INFO
ADDED
|
@@ -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,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,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 @@
|
|
|
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)
|