Skip to content

Commit 0ee85fa

Browse files
monyedavidlihaoyi
andauthored
[BACKPORT] Write a doc example on multi-language Mill projects (#4494)
Back-port PR #4476 into `0.12.x` --------- Co-authored-by: Li Haoyi <haoyi.sg@gmail.com>
1 parent 9aa054e commit 0ee85fa

File tree

15 files changed

+574
-4
lines changed

15 files changed

+574
-4
lines changed

docs/modules/ROOT/nav.adoc

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@
102102
** xref:extending/meta-build.adoc[]
103103
** xref:extending/example-typescript-support.adoc[]
104104
** xref:extending/example-python-support.adoc[]
105+
* xref:large/large.adoc[]
106+
** xref:large/selective-execution.adoc[]
107+
** xref:large/multi-file-builds.adoc[]
108+
** xref:large/multi-language-builds.adoc[]
105109
// This section focuses on diving into deeper, more advanced topics for Mill.
106110
// These are things that most Mill developers would not encounter day to day,
107111
// but people developing Mill plugins or working on particularly large or
108112
// sophisticated Mill builds will need to understand.
109-
* xref:large/large.adoc[]
110-
** xref:large/selective-execution.adoc[]
111-
** xref:large/multi-file-builds.adoc[]
112-
113113
* Mill In Depth
114114
** xref:depth/sandboxing.adoc[]
115115
** xref:depth/execution-model.adoc[]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
= Multi-Language Builds
2+
:page-aliases: Multi_Language_Builds.adoc
3+
4+
include::partial$example/large/multi/14-multi-language.adoc[]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package build
2+
import mill._, javascriptlib._, pythonlib._, javalib._
3+
4+
object client extends ReactScriptsModule
5+
6+
object `sentiment-analysis` extends PythonModule {
7+
def mainScript = Task.Source { millSourcePath / "src" / "foo.py" }
8+
9+
def pythonDeps = Seq("textblob==0.19.0")
10+
11+
object test extends PythonTests with pythonlib.TestModule.Unittest
12+
}
13+
14+
object server extends JavaModule {
15+
def ivyDeps = Agg(
16+
ivy"org.springframework.boot:spring-boot-starter-web:2.5.6",
17+
ivy"org.springframework.boot:spring-boot-starter-actuator:2.5.6"
18+
)
19+
20+
/** Bundle client & sentiment-analysis as resource */
21+
def resources = Task.Sources {
22+
os.copy(client.bundle().path, Task.dest / "static")
23+
os.makeDir.all(Task.dest / "analysis")
24+
os.copy(`sentiment-analysis`.bundle().path, Task.dest / "analysis" / "analysis.pex")
25+
super.resources() ++ Seq(PathRef(Task.dest))
26+
}
27+
28+
object test extends JavaTests with javalib.TestModule.Junit5 {
29+
def ivyDeps = super.ivyDeps() ++ Agg(
30+
ivy"org.springframework.boot:spring-boot-starter-test:2.5.6"
31+
)
32+
}
33+
}
34+
35+
// This example demonstrates a simple multi-langauge project,
36+
// running a `spring boot webserver` serving a `react client` and interacting with a `python binary`
37+
// through the web-server api.
38+
39+
/** Usage
40+
41+
> mill client.test
42+
PASS src/test/App.test.tsx
43+
...Text Analysis Tool
44+
...renders the app with initial UI...
45+
...displays sentiment result...
46+
...
47+
Test Suites:...1 passed, 1 total
48+
Tests:...2 passed, 2 total
49+
...
50+
51+
> mill sentiment-analysis.test
52+
...
53+
test_negative_sentiment... ok
54+
test_neutral_sentiment... ok
55+
test_positive_sentiment... ok
56+
...
57+
Ran 3 tests...
58+
...
59+
OK
60+
...
61+
62+
> mill server.test
63+
...com.example.ServerTest#shouldReturnStaticPage() finished...
64+
...com.example.ServerTest#shouldReturnPositiveAnalysis() finished...
65+
...com.example.ServerTest#shouldReturnNegativeAnalysis() finished...
66+
67+
> mill server.runBackground
68+
69+
> curl http://localhost:8086
70+
...<title>Sentiment Analysis Tool</title>...
71+
72+
> curl -X POST http://localhost:8086/api/analysis -H "Content-Type: text/plain" --data "This is awesome!" # Make request to the analysis api
73+
Positive sentiment (polarity: 1.0)
74+
75+
> mill clean server.runBackground
76+
*/
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
<meta name="theme-color" content="#000000" />
8+
<meta
9+
name="description"
10+
content="Web site created using create-react-app"
11+
/>
12+
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13+
<!--
14+
manifest.json provides metadata used when your web app is installed on a
15+
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
16+
-->
17+
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18+
<!--
19+
Notice the use of %PUBLIC_URL% in the tags above.
20+
It will be replaced with the URL of the `public` folder during the build.
21+
Only files inside the `public` folder can be referenced from the HTML.
22+
23+
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
24+
work correctly both with client-side routing and a non-root public URL.
25+
Learn how to configure a non-root public URL by running `npm run build`.
26+
-->
27+
<title>Sentiment Analysis Tool</title>
28+
</head>
29+
<body>
30+
<noscript>You need to enable JavaScript to run this app.</noscript>
31+
<div id="root"></div>
32+
<!--
33+
This HTML file is a template.
34+
If you open it directly in the browser, you will see an empty page.
35+
36+
You can add webfonts, meta tags, or analytics to this file.
37+
The build step will place the bundled scripts into the <body> tag.
38+
39+
To begin the development, run `npm start` or `yarn start`.
40+
To create a production bundle, use `npm run build` or `yarn build`.
41+
-->
42+
</body>
43+
</html>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
* {
2+
margin: 0;
3+
padding: 0;
4+
box-sizing: border-box;
5+
font-family: 'Arial', sans-serif;
6+
}
7+
8+
body {
9+
background: #f4f4f4;
10+
height: 100vh;
11+
display: flex;
12+
justify-content: center;
13+
align-items: center;
14+
}
15+
16+
.app-container {
17+
background: #fff;
18+
border-radius: 12px;
19+
padding: 40px;
20+
width: 400px;
21+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
22+
text-align: center;
23+
}
24+
25+
h1 {
26+
margin-bottom: 20px;
27+
font-size: 1.8rem;
28+
color: #333;
29+
}
30+
31+
.analysis-form {
32+
display: flex;
33+
flex-direction: column;
34+
gap: 10px;
35+
}
36+
37+
textarea {
38+
width: 100%;
39+
height: 120px;
40+
padding: 10px;
41+
font-size: 1rem;
42+
border: 1px solid #ccc;
43+
border-radius: 8px;
44+
resize: none;
45+
outline: none;
46+
}
47+
48+
textarea:focus {
49+
border-color: #007bff;
50+
}
51+
52+
button {
53+
padding: 10px;
54+
background: #007bff;
55+
color: #fff;
56+
border: none;
57+
border-radius: 8px;
58+
cursor: pointer;
59+
font-size: 1rem;
60+
}
61+
62+
button:hover {
63+
background: #0056b3;
64+
}
65+
66+
button:disabled {
67+
background: #b0c4de;
68+
cursor: not-allowed;
69+
}
70+
71+
.result-container {
72+
margin-top: 20px;
73+
padding: 15px;
74+
border-radius: 8px;
75+
color: #fff;
76+
font-weight: bold;
77+
}
78+
79+
/* Sentiment-based styles */
80+
.result-container.positive {
81+
background-color: #28a745; /* Green for positive */
82+
}
83+
84+
.result-container.negative {
85+
background-color: #dc3545; /* Red for negative */
86+
}
87+
88+
.result-container.neutral {
89+
background-color: #007bff; /* Blue for neutral */
90+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, {useState} from 'react';
2+
import 'src/App.css';
3+
4+
const App = () => {
5+
const [inputText, setInputText] = useState('');
6+
const [result, setResult] = useState('');
7+
const [loading, setLoading] = useState(false);
8+
const [sentiment, setSentiment] = useState('neutral');
9+
10+
const handleSubmit = async (e) => {
11+
e.preventDefault();
12+
setLoading(true);
13+
setResult('');
14+
setSentiment('neutral');
15+
16+
try {
17+
const response = await fetch('http://localhost:8086/api/analysis', {
18+
method: 'POST',
19+
headers: {
20+
'Content-Type': 'text/plain',
21+
},
22+
body: inputText,
23+
});
24+
25+
if (response.ok) {
26+
const responseData = await response.text();
27+
setResult(responseData);
28+
29+
// Determine sentiment from the response
30+
const polarityMatch = responseData.match(/polarity: ([+-]?[0-9]*\.?[0-9]+)/);
31+
if (polarityMatch) {
32+
const polarity = parseFloat(polarityMatch[1]);
33+
34+
if (polarity > 0) {
35+
setSentiment('positive');
36+
} else if (polarity < 0) {
37+
setSentiment('negative');
38+
} else {
39+
setSentiment('neutral');
40+
}
41+
}
42+
} else {
43+
setResult('Error occurred during analysis.');
44+
}
45+
} catch (error) {
46+
setResult('Network error: Could not connect to the server.');
47+
} finally {
48+
setLoading(false);
49+
}
50+
};
51+
52+
return (
53+
<div className="app-container">
54+
<h1>Text Analysis Tool</h1>
55+
<form onSubmit={handleSubmit} className="analysis-form">
56+
<textarea
57+
value={inputText}
58+
onChange={(e) => setInputText(e.target.value)}
59+
placeholder="Enter your text here..."
60+
required
61+
/>
62+
<button type="submit" disabled={loading}>
63+
{loading ? 'Analyzing...' : 'Analyze'}
64+
</button>
65+
</form>
66+
{result && (
67+
<div className={`result-container ${sentiment}`}>
68+
<h2>Analysis Result:</h2>
69+
<p>{result}</p>
70+
</div>
71+
)}
72+
</div>
73+
);
74+
};
75+
76+
export default App;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
import ReactDOM from 'react-dom/client';
3+
import App from './app/App';
4+
5+
const root = ReactDOM.createRoot(
6+
document.getElementById('root') as HTMLElement
7+
);
8+
root.render(
9+
<React.StrictMode>
10+
<App />
11+
</React.StrictMode>
12+
);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import '@testing-library/jest-dom'; // Import jest-dom matchers
4+
import App from 'app/App';
5+
6+
// Mock the fetch API
7+
global.fetch = jest.fn();
8+
9+
describe('Text Analysis Tool', () => {
10+
beforeEach(() => {
11+
(fetch as jest.Mock).mockClear();
12+
});
13+
14+
test('renders the app with initial UI', () => {
15+
render(<App />);
16+
17+
// Check for the page title
18+
expect(screen.getByText('Text Analysis Tool')).toBeInTheDocument();
19+
20+
// Check for the input form
21+
expect(screen.getByPlaceholderText('Enter your text here...')).toBeInTheDocument();
22+
expect(screen.getByText('Analyze')).toBeInTheDocument();
23+
});
24+
25+
test('displays sentiment result', async () => {
26+
// Mock the fetch response for positive sentiment
27+
(fetch as jest.Mock).mockResolvedValueOnce({
28+
ok: true,
29+
text: async () => 'Positive sentiment (polarity: 0.8)',
30+
});
31+
32+
render(<App />);
33+
34+
// Simulate user input and form submission
35+
fireEvent.change(screen.getByPlaceholderText('Enter your text here...'), {
36+
target: { value: 'This is amazing!' },
37+
});
38+
fireEvent.click(screen.getByText('Analyze'));
39+
40+
// Wait for the result to appear
41+
await waitFor(() => screen.getByText('Analysis Result:'));
42+
43+
// Check that the result is displayed
44+
expect(screen.getByText('Positive sentiment (polarity: 0.8)')).toBeInTheDocument();
45+
expect(screen.getByText('Analysis Result:').parentElement).toHaveClass('positive');
46+
});
47+
});

0 commit comments

Comments
 (0)