Skip to content

Commit 1f07ee7

Browse files
iceHub82claude
andcommitted
fix: give each plot request its own PNG and make the Python path configurable
A fixed wwwroot/plots/plot_{ticker}.png let two callers requesting the same ticker overwrite each other between SavePlot and the browser's follow-up GET. Each request now renders to plot_{ticker}_{guid}.png, with stale files swept on the next request. Program.cs resolves the Python home and venv from configuration (Python:Home / Python:VirtualEnvironment) before falling back to the sibling-directory default, and creates the plots directory if it is absent. Also drops the ~45 lines of commented-out copy-paste inside the work-in-progress backtest endpoint - it duplicated the stock endpoint verbatim and git has it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5890788 commit 1f07ee7

4 files changed

Lines changed: 165 additions & 79 deletions

File tree

PythonNet.App.Web.Tests/MinimalApiExtensionTests.cs

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,35 @@ public class MinimalApiExtensionTests
99
private static readonly DateTime Start = new(2024, 10, 24);
1010
private static readonly DateTime End = new(2024, 10, 27);
1111

12-
// Renders nothing but a marker so the assertions can tell "reached Python" from "rejected early".
13-
private static string Marker(Stock s, string start, string end) => $"{s.Ticker}|{s.FileName}|{start}|{end}";
12+
/// <summary>Stands in for the Python call chain, recording what the endpoint asked it to render.</summary>
13+
private sealed class Recorder
14+
{
15+
public List<(Stock Stock, string Start, string End, string PlotName)> Calls { get; } = [];
16+
17+
public void Save(Stock stock, string start, string end, string plotName) =>
18+
Calls.Add((stock, start, end, plotName));
19+
}
1420

1521
[Theory]
1622
[InlineData(1, "AAPL", "Apple", "aapl_27.10.24-24.10.24.csv")]
1723
[InlineData(2, "MSFT", "Microsoft", "msft_27.10.24-24.10.24.csv")]
1824
[InlineData(3, "IBM", "IBM", "ibm_27.10.24-24.10.24.csv")]
19-
public void KnownTicker_RendersWithResolvedStock(int id, string ticker, string title, string file)
25+
public void KnownTicker_PassesResolvedStockAndIsoDates(int id, string ticker, string title, string file)
2026
{
21-
var result = MinimalApiExtension.StockPlot(id, Start, End, Marker);
27+
var recorder = new Recorder();
28+
29+
var result = MinimalApiExtension.StockPlot(id, Start, End, recorder.Save);
30+
31+
var call = Assert.Single(recorder.Calls);
32+
Assert.Equal(ticker, call.Stock.Ticker);
33+
Assert.Equal(title, call.Stock.Title);
34+
Assert.Equal(file, call.Stock.FileName);
35+
Assert.Equal("2024-10-24", call.Start);
36+
Assert.Equal("2024-10-27", call.End);
2237

2338
var content = Assert.IsType<ContentHttpResult>(result);
24-
Assert.Equal($"{ticker}|{file}|2024-10-24|2024-10-27", content.ResponseContent);
2539
Assert.Equal("text/html", content.ContentType);
26-
Assert.Equal(title, MinimalApiExtension.Tickers[id].Title);
40+
Assert.Contains($"/dynamic-plots/{call.PlotName}.png", content.ResponseContent);
2741
}
2842

2943
[Theory]
@@ -33,47 +47,108 @@ public void KnownTicker_RendersWithResolvedStock(int id, string ticker, string t
3347
[InlineData(99)]
3448
public void UnknownTicker_IsRejectedWithoutCallingPython(int id)
3549
{
36-
var called = false;
50+
var recorder = new Recorder();
3751

38-
var result = MinimalApiExtension.StockPlot(id, Start, End, (_, _, _) =>
39-
{
40-
called = true;
41-
return "should not happen";
42-
});
52+
var result = MinimalApiExtension.StockPlot(id, Start, End, recorder.Save);
4353

4454
var bad = Assert.IsType<BadRequest<string>>(result);
4555
Assert.Contains($"Unknown ticker '{id}'", bad.Value);
46-
Assert.False(called);
56+
Assert.Empty(recorder.Calls);
4757
}
4858

4959
[Fact]
50-
public void StartAfterEnd_IsRejected()
60+
public void StartAfterEnd_IsRejectedWithoutCallingPython()
5161
{
52-
var result = MinimalApiExtension.StockPlot(1, End, Start, Marker);
62+
var recorder = new Recorder();
63+
64+
var result = MinimalApiExtension.StockPlot(1, End, Start, recorder.Save);
5365

5466
var bad = Assert.IsType<BadRequest<string>>(result);
5567
Assert.Contains("startDate must be on or before endDate", bad.Value);
68+
Assert.Empty(recorder.Calls);
5669
}
5770

5871
[Fact]
5972
public void SameStartAndEnd_IsAllowed()
6073
{
61-
var result = MinimalApiExtension.StockPlot(1, Start, Start, Marker);
74+
var result = MinimalApiExtension.StockPlot(1, Start, Start, new Recorder().Save);
6275

6376
Assert.IsType<ContentHttpResult>(result);
6477
}
6578

6679
[Fact]
67-
public void RenderFailure_BecomesProblemResponse()
80+
public void SaveFailure_BecomesProblemResponse()
6881
{
6982
var result = MinimalApiExtension.StockPlot(1, Start, End,
70-
(_, _, _) => throw new FileNotFoundException("Data/aapl_27.10.24-24.10.24.csv"));
83+
(_, _, _, _) => throw new FileNotFoundException("Data/aapl_27.10.24-24.10.24.csv"));
7184

7285
var problem = Assert.IsType<ProblemHttpResult>(result);
7386
Assert.Equal(StatusCodes.Status500InternalServerError, problem.StatusCode);
7487
Assert.Contains("Could not plot AAPL", problem.ProblemDetails.Detail);
7588
}
7689

90+
[Fact]
91+
public void ConcurrentRequestsForOneTicker_GetDistinctPlotNames()
92+
{
93+
var recorder = new Recorder();
94+
95+
var htmlA = (ContentHttpResult)MinimalApiExtension.StockPlot(1, Start, End, recorder.Save);
96+
var htmlB = (ContentHttpResult)MinimalApiExtension.StockPlot(1, Start, End, recorder.Save);
97+
98+
var (first, second) = (recorder.Calls[0].PlotName, recorder.Calls[1].PlotName);
99+
Assert.StartsWith("plot_AAPL_", first);
100+
Assert.StartsWith("plot_AAPL_", second);
101+
Assert.NotEqual(first, second);
102+
Assert.NotEqual(htmlA.ResponseContent, htmlB.ResponseContent);
103+
}
104+
105+
[Fact]
106+
public void SweepStalePlots_DeletesOnlyPlotsPastTheirAge()
107+
{
108+
var cwd = Directory.GetCurrentDirectory();
109+
var scratch = Directory.CreateTempSubdirectory().FullName;
110+
try
111+
{
112+
Directory.SetCurrentDirectory(scratch);
113+
var plots = Directory.CreateDirectory(MinimalApiExtension.PlotDirectory).FullName;
114+
115+
var stale = Path.Combine(plots, "plot_AAPL_old.png");
116+
var fresh = Path.Combine(plots, "plot_AAPL_new.png");
117+
var other = Path.Combine(plots, "keep-me.txt");
118+
foreach (var f in new[] { stale, fresh, other }) File.WriteAllText(f, "x");
119+
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddMinutes(-30));
120+
121+
MinimalApiExtension.SweepStalePlots(TimeSpan.FromMinutes(5));
122+
123+
Assert.False(File.Exists(stale));
124+
Assert.True(File.Exists(fresh));
125+
Assert.True(File.Exists(other));
126+
}
127+
finally
128+
{
129+
Directory.SetCurrentDirectory(cwd);
130+
Directory.Delete(scratch, recursive: true);
131+
}
132+
}
133+
134+
[Fact]
135+
public void SweepStalePlots_IsANoOpWhenThereIsNoPlotDirectory()
136+
{
137+
var cwd = Directory.GetCurrentDirectory();
138+
var scratch = Directory.CreateTempSubdirectory().FullName;
139+
try
140+
{
141+
Directory.SetCurrentDirectory(scratch);
142+
MinimalApiExtension.SweepStalePlots();
143+
Assert.False(Directory.Exists(MinimalApiExtension.PlotDirectory));
144+
}
145+
finally
146+
{
147+
Directory.SetCurrentDirectory(cwd);
148+
Directory.Delete(scratch, recursive: true);
149+
}
150+
}
151+
77152
[Fact]
78153
public void EveryTickerMapsToAShippedCsv()
79154
{

PythonNet.App.Web/MinimalApiExtension.cs

Lines changed: 48 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,66 +7,23 @@ public static class MinimalApiExtension
77
public static void MinimalApi(this WebApplication app)
88
{
99
app.MapGet("/api/dashboard/stock", (IUtil util, IPlot plotter, int tickerDd, DateTime startDate, DateTime endDate, string? openCb, string? closeCb, string? theme) =>
10-
StockPlot(tickerDd, startDate, endDate, (stock, start, end) =>
10+
StockPlot(tickerDd, startDate, endDate, (stock, start, end, plotName) =>
1111
{
1212
var data = util.ReadCsv(stock.FileName);
1313
var filtered = util.DateFilter(data, start, end);
1414
var sorted = util.SortData(filtered, "Date", true);
1515
var plot = plotter.Plot(sorted, stock.Ticker, stock.Title, openCb!, closeCb!, start, end,
1616
theme == "dark" ? "dark" : "light");
1717

18-
// ponytail: one PNG per ticker, overwritten in place. Concurrent requests for the same
19-
// ticker race on this file; switch to a per-request name if this ever serves >1 user.
20-
util.SavePlot(plot, "wwwroot/plots/", $"plot_{stock.Ticker}", "png");
21-
22-
return $"<img class='img-fluid' src='/dynamic-plots/plot_{stock.Ticker}.png' alt='PNG Image'>";
18+
util.SavePlot(plot, PlotDirectory + Path.DirectorySeparatorChar, plotName, "png");
2319
}));
2420

25-
app.MapGet("/api/dashboard/backtest", (IUtil util/*, IPlot plotter, int tickerDd, DateTime startDate, DateTime endDate, string? openCb, string? closeCb, string theme*/) =>
21+
// TODO: work in progress - fetches live data but does not plot it yet.
22+
app.MapGet("/api/dashboard/backtest", (IUtil util) =>
2623
{
27-
//Stock stock = new();
28-
//switch (tickerDd)
29-
//{
30-
// case 1:
31-
// {
32-
// stock.Ticker = "AAPL";
33-
// stock.Title = "Apple";
34-
// stock.FileName = "aapl_27.10.24-24.10.24.csv";
35-
// }
36-
// break;
37-
// case 2:
38-
// {
39-
// stock.Ticker = "MSFT";
40-
// stock.Title = "Microsoft";
41-
// stock.FileName = "msft_27.10.24-24.10.24.csv";
42-
// }
43-
// break;
44-
// case 3:
45-
// {
46-
// stock.Ticker = "IBM";
47-
// stock.Title = "IBM";
48-
// stock.FileName = "ibm_27.10.24-24.10.24.csv";
49-
// }
50-
// break;
51-
//}
52-
53-
//var start = startDate.ToString("yyyy-MM-dd");
54-
//var end = endDate.ToString("yyyy-MM-dd");
55-
56-
//var data = util.ReadCsv(stock.FileName!);
57-
//var filtered = util.DateFilter(data, start, end);
58-
//var sorted = util.SortData(filtered, "Date", true);
59-
//var plot = plotter.Plot(sorted, stock.Ticker!, stock.Title!, openCb!, closeCb!, start, end, theme);
60-
61-
//util.SavePlot(plot, "wwwroot/plots/", $"plot_{stock.Ticker}", "png");
62-
63-
var data = util.DownloadData("AAPL", "2024-10-20", "2024-10-30");
64-
65-
//var html = $"<img class='img-fluid' src='/dynamic-plots/plot_{stock.Ticker}.png' alt='PNG Image'>";
66-
var html = $"<img class='img-fluid' src='/dynamic-plots/plot_.png' alt='PNG Image'>";
67-
68-
69-
return Content(html);
24+
var data = util.DownloadData("AAPL", "2024-10-20", "2024-10-30");
25+
26+
return Content("<img class='img-fluid' src='/dynamic-plots/plot_.png' alt='PNG Image'>");
7027
});
7128
}
7229

@@ -77,25 +34,62 @@ public static void MinimalApi(this WebApplication app)
7734
[3] = new("IBM", "IBM", "ibm_27.10.24-24.10.24.csv"),
7835
};
7936

80-
// ponytail: `render` is the only seam here - it lets the validation and failure branches be tested
81-
// without booting a Python runtime. Split into a service class if this grows past one call chain.
37+
internal const string PlotDirectory = "wwwroot/plots";
38+
39+
/// <summary>
40+
/// Validates the request, hands a unique output name to <paramref name="savePlot"/>, and returns the
41+
/// HTMX fragment pointing at it. <paramref name="savePlot"/> is the only seam - it lets the validation,
42+
/// naming and failure branches be tested without booting a Python runtime.
43+
/// </summary>
8244
internal static IResult StockPlot(int tickerDd, DateTime startDate, DateTime endDate,
83-
Func<Stock, string, string, string> render)
45+
Action<Stock, string, string, string> savePlot)
8446
{
8547
if (!Tickers.TryGetValue(tickerDd, out var stock))
8648
return Results.BadRequest($"Unknown ticker '{tickerDd}'. Expected one of: {string.Join(", ", Tickers.Keys)}.");
8749

8850
if (startDate > endDate)
8951
return Results.BadRequest("startDate must be on or before endDate.");
9052

53+
// Unique per request: a fixed plot_{ticker}.png let concurrent callers overwrite each other's
54+
// chart between SavePlot and the browser's follow-up GET.
55+
var plotName = $"plot_{stock.Ticker}_{Guid.NewGuid():N}";
56+
9157
try
9258
{
93-
return Content(render(stock, startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd")));
59+
savePlot(stock, startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd"), plotName);
9460
}
9561
catch (Exception ex)
9662
{
9763
return Results.Problem($"Could not plot {stock.Ticker}: {ex.Message}", statusCode: 500);
9864
}
65+
66+
SweepStalePlots();
67+
68+
return Content($"<img class='img-fluid' src='/dynamic-plots/{plotName}.png' alt='PNG Image'>");
69+
}
70+
71+
/// <summary>
72+
/// Each PNG is fetched once, right after the response. Drop anything the browser has long since had.
73+
/// </summary>
74+
// ponytail: swept inline on the next request rather than by a background service - it is a handful
75+
// of File.Delete calls. Move it to a hosted service if the plots directory ever gets big enough to stat.
76+
internal static void SweepStalePlots(TimeSpan? maxAge = null)
77+
{
78+
if (!Directory.Exists(PlotDirectory)) return;
79+
80+
var cutoff = DateTime.UtcNow - (maxAge ?? TimeSpan.FromMinutes(5));
81+
82+
foreach (var file in Directory.EnumerateFiles(PlotDirectory, "plot_*.png"))
83+
{
84+
try
85+
{
86+
if (File.GetLastWriteTimeUtc(file) < cutoff) File.Delete(file);
87+
}
88+
catch (IOException)
89+
{
90+
// Raced with another request still serving it; the next sweep gets it.
91+
}
92+
}
9993
}
10094

10195
private static IResult Content(string content)

PythonNet.App.Web/Program.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55
var builder = WebApplication.CreateBuilder(args);
66

77
var pythonBuilder = builder.Services.WithPython();
8-
var home = Path.Join(Environment.CurrentDirectory, "..", "PythonNet.Python");
9-
var venv = Path.Join(home, ".venv");
8+
9+
// Overridable via appsettings or Python__Home / Python__VirtualEnvironment env vars, so the app can run
10+
// from a published layout where the sources are not a sibling directory of the working directory.
11+
var home = builder.Configuration["Python:Home"]
12+
?? Path.Join(Environment.CurrentDirectory, "..", "PythonNet.Python");
13+
var venv = builder.Configuration["Python:VirtualEnvironment"] ?? Path.Join(home, ".venv");
1014
pythonBuilder
1115
.WithHome(home)
1216
.WithVirtualEnvironment(venv)
@@ -22,8 +26,12 @@
2226

2327
var app = builder.Build();
2428

29+
// Generated charts land here at runtime, so the directory may not exist on a fresh checkout.
30+
var plots = Path.Combine(Directory.GetCurrentDirectory(), MinimalApiExtension.PlotDirectory);
31+
Directory.CreateDirectory(plots);
32+
2533
app.UseStaticFiles(new StaticFileOptions{
26-
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "plots")),
34+
FileProvider = new PhysicalFileProvider(plots),
2735
RequestPath = "/dynamic-plots",
2836
OnPrepareResponse = r => {
2937
r.Context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0";

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,17 @@ On first start CSnakes creates `PythonNet.Python/.venv` and pip-installs
2929
`PythonNet.Python/requirements.txt` into it. This takes a minute or two; progress
3030
is visible in the console output. Subsequent starts reuse the venv.
3131

32-
> Run from `PythonNet.App.Web`, not the solution root — `Program.cs` resolves the
33-
> Python home as `<cwd>/../PythonNet.Python`, and `util.read_csv` resolves data
32+
> Run from `PythonNet.App.Web`, not the solution root — `Program.cs` defaults the
33+
> Python home to `<cwd>/../PythonNet.Python`, and `util.read_csv` resolves data
3434
> files as `Data/<file>` relative to the working directory.
3535
36+
To point at a Python home elsewhere (a published layout, a shared venv), override
37+
either setting via `appsettings.json` or the environment:
38+
39+
```bash
40+
Python__Home=/srv/pythonnet Python__VirtualEnvironment=/srv/venv dotnet run
41+
```
42+
3643
## Running the tests
3744

3845
```bash
@@ -56,8 +63,10 @@ Both are run by `.github/workflows/build.yml` on every push and pull request.
5663
4. **`MinimalApiExtension.cs`** injects them into `GET /api/dashboard/stock`, which
5764
validates its query parameters, then chains
5865
`read_csv → date_filter → sort_data → plot → save_plot` and returns an `<img>`
59-
fragment for HTMX to swap into the page. Rendered PNGs are written to
60-
`wwwroot/plots/` and served from `/dynamic-plots` with caching disabled.
66+
fragment for HTMX to swap into the page. Each request renders to its own
67+
`wwwroot/plots/plot_{ticker}_{guid}.png` — served from `/dynamic-plots` with
68+
caching disabled — so concurrent callers cannot overwrite each other's chart.
69+
Files older than five minutes are swept on the next request.
6170

6271
Adding a Python function is therefore: annotate it, rebuild, call the generated
6372
C# method. Adding a *new* `.py` file also needs an `<AdditionalFiles>` entry.

0 commit comments

Comments
 (0)