NuGet Pack (#15)

This commit is contained in:
Patrick Stevens
2023-12-27 22:47:35 +00:00
committed by GitHub
parent c4bfd5eedb
commit f1304df21d
11 changed files with 239 additions and 188 deletions

View File

@@ -7,6 +7,12 @@
"commands": [
"fantomas"
]
},
"fsharp-analyzers": {
"version": "0.22.0",
"commands": [
"fsharp-analyzers"
]
}
}
}

View File

@@ -39,6 +39,25 @@ jobs:
- name: Test
run: nix develop --command dotnet test --no-build --verbosity normal --configuration ${{matrix.config}}
analyzers:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v24
with:
extra_nix_config: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- name: Prepare analyzers
run: nix develop --command dotnet restore analyzers/analyzers.fsproj
- name: Run analyzers
run: nix run .#fsharp-analyzers -- --project ./WoofWare.Myriad.Plugins/WoofWare.Myriad.Plugins.fsproj --analyzers-path ./.analyzerpackages/g-research.fsharp.analyzers/0.6.0/ --verbosity detailed --report ./analysis.sarif --treat-as-error GRA-STRING-001 GRA-STRING-002 GRA-STRING-003 GRA-UNIONCASE-001 GRA-INTERPOLATED-001 GRA-TYPE-ANNOTATE-001 GRA-VIRTUALCALL-001 GRA-IMMUTABLECOLLECTIONEQUALITY-001 GRA-JSONOPTS-001 GRA-LOGARGFUNCFULLAPP-001
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: analysis.sarif
build-nix:
runs-on: ubuntu-latest
steps:
@@ -89,7 +108,7 @@ jobs:
extra_nix_config: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- name: Run link checker
run: nix develop --command markdown-link-check README.md
run: nix develop --command markdown-link-check README.md CONTRIBUTING.md
flake-check:
name: Check flake
@@ -104,8 +123,43 @@ jobs:
- name: Flake check
run: nix flake check
nuget-pack:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # so that NerdBank.GitVersioning has access to history
- name: Install Nix
uses: cachix/install-nix-action@v24
with:
extra_nix_config: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- name: Restore dependencies
run: nix develop --command dotnet restore
- name: Build
run: nix develop --command dotnet build --no-restore --configuration Release
- name: Pack
run: nix develop --command dotnet pack --configuration Release
- name: Upload NuGet artifact
uses: actions/upload-artifact@v4
with:
name: nuget-package
path: WoofWare.Myriad.Plugins/bin/Release/WoofWare.Myriad.Plugins.*.nupkg
expected-pack:
needs: [nuget-pack]
runs-on: ubuntu-latest
steps:
- name: Download NuGet artifact
uses: actions/download-artifact@v4
with:
name: nuget-package
- name: Check NuGet contents
# Verify that there is exactly one nupkg in the artifact that would be NuGet published
run: if [[ $(find . -maxdepth 1 -name 'WoofWare.Myriad.Plugins.*.nupkg' -printf c | wc -c) -ne "1" ]]; then exit 1; fi
all-required-checks-complete:
needs: [check-dotnet-format, check-nix-format, build, build-nix, linkcheck, flake-check]
needs: [check-dotnet-format, check-nix-format, build, build-nix, linkcheck, flake-check, analyzers, nuget-pack, expected-pack]
runs-on: ubuntu-latest
steps:
- run: echo "All required checks complete."

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ riderModule.iml
*.sln.DotSettings.user
.DS_Store
result
.analyzerpackages/
analysis.sarif

55
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,55 @@
# Contributing
The main project fork lives [on GitHub](https://github.com/Smaug123/WoofWare.Myriad).
Contributions are welcome, but I am generally very opinionated about both style and content.
I also can't commit to looking at anything in a particularly timely manner (or at all, though I expect I will try).
In general my aesthetics lead me to accept correctness fixes much more readily than other changes.
## Issues
Please raise bug reports and feature requests as Issues on [the main GitHub project](https://github.com/Smaug123/WoofWare.Myriad/issues).
## Pull requests
Before embarking on a large change, I strongly recommend checking via a GitHub Issue first that I'm likely to accept it.
You may find that the following guidelines will help you produce a change that I accept:
* Keep your change as small and focused as is practical.
* Ensure that your change is thoroughly tested.
* Document any choices you make which are not immediately obvious.
* Explain why your change is necessary or desirable.
## On your first checkout
There are pull request checks on this repo, enforcing [Fantomas](https://github.com/fsprojects/fantomas/)-compliant formatting according to the [G-Research style guidelines](https://github.com/G-Research/fsharp-formatting-conventions/).
After checking out the repo, you may wish to add a pre-push hook to ensure locally that formatting is complete, rather than having to wait for the CI checks to tell you that you haven't formatted your code.
Consider performing the following command to set this up in the repo:
```bash
git config core.hooksPath hooks/
```
Before your first push (but only once), you will need to install the [.NET local tools](https://docs.microsoft.com/en-us/dotnet/core/tools/local-tools-how-to-use) which form part of the pre-push hook:
```bash
dotnet tool restore
```
In future, some commits (such as big-bang formatting commits) may be recorded for convenience in `.git-blame-ignore-revs`.
Consider performing the following command to have `git blame` ignore these commits, when we ever create any:
```bash
git config blame.ignoreRevsFile .git-blame-ignore-revs
```
## Dependencies
I try to keep this repository's dependencies as few as possible, because (for example) any consumer of the source generator will also consume this project via the attributes.
When adding dependencies, you will need to `nix run .#fetchDeps` to obtain a new copy of [the dependency lockfile](./nix/deps.nix).
## Branch strategy
Releases are made from the `main` branch.
## License
This project is licensed with the MIT license, a copy of which you can find at the repository root.

View File

@@ -5,217 +5,109 @@ namespace PureGym
open System
open System.Text.Json.Serialization
/// Describes the opening hours of a given gym.
[<WoofWare.Myriad.Plugins.JsonParse>]
type GymOpeningHours =
{
/// If this is true, there should be no OpeningHours (but nothing enforces that).
IsAlwaysOpen : bool
/// This is a pretty unstructured list, which is in general not really parseable: it's human-readable only.
OpeningHours : string list
}
/// Human-readable representation
override this.ToString () =
if this.IsAlwaysOpen then
"always open"
else
this.OpeningHours |> String.concat ", "
/// How a human can authenticate with a gym when they physically try to enter it
[<WoofWare.Myriad.Plugins.JsonParse>]
type GymAccessOptions =
{
/// This gym has PIN entry pads
PinAccess : bool
/// This gym has a QR code scanner. QR codes can be generated with the PureGym app.
QrCodeAccess : bool
}
/// Where a gym is on the Earth
[<WoofWare.Myriad.Plugins.JsonParse>]
type GymLocation =
{
/// Measured in degrees
[<JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)>]
Longitude : float
/// Measured in degrees
[<JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)>]
Latitude : float
}
/// The postal address of a gym
[<WoofWare.Myriad.Plugins.JsonParse>]
type GymAddress =
{
/// E.g. "Canterbury Court"
[<JsonRequired>]
AddressLine1 : string
/// E.g. "Units 4, 4A, 5 And 5A"
AddressLine2 : string option
/// E.g. "Kennington Park"
AddressLine3 : string option
/// E.g. "LONDON"
[<JsonRequired>]
Town : string
County : string option
/// E.g. "SW9 6DE"
[<JsonRequired>]
Postcode : string
}
/// Human-readable statement of the address
override this.ToString () =
[
yield Some this.AddressLine1
yield this.AddressLine2
yield this.AddressLine3
match this.County with
| None -> yield Some $"%s{this.Town} %s{this.Postcode}"
| Some county ->
yield Some this.Town
yield Some $"%s{county} %s{this.Postcode}"
]
|> Seq.choose id
|> String.concat "\n"
/// Metadata about a physical gym
[<WoofWare.Myriad.Plugins.JsonParse>]
type Gym =
{
// The following fields are returned but are always null
// ReasonsToJoin : string
// VirtualTourUrl : Uri
// PersonalTrainersUrl : Uri
// WebViewUrl : Uri
// FloorPlanUrl : Uri
// StaffMembers : string
/// The name of this gym, e.g. "London Oval"
[<JsonRequired>]
Name : string
/// This gym's ID in the PureGym system, e.g. 19
[<JsonRequired>]
Id : int
/// I don't know what this status is. Please tell me if you know!
[<JsonRequired>]
Status : int
/// Postal address of this gym
[<JsonRequired>]
Address : GymAddress
/// Phone number of this gym, e.g. "+44 1234 567890"
[<JsonRequired>]
PhoneNumber : string
/// Contact email address for this gym's staff
[<JsonRequired>]
EmailAddress : string
/// When this gym is open
[<JsonRequired>]
GymOpeningHours : GymOpeningHours
/// How a human can physically authenticate when they physically enter this gym
[<JsonRequired>]
AccessOptions : GymAccessOptions
/// Where this gym is physically located
[<JsonRequired>]
Location : GymLocation
/// The IANA time zone this gym observes, e.g. "Europe/London"
[<JsonRequired>]
TimeZone : string
/// This is a date-time in the format yyyy-MM-ddTHH:mm:ss+01 Europe/London
ReopenDate : string
}
/// Human-readable representation of the most important information about this gym
override this.ToString () =
$"""%s{this.Name} (%i{this.Id})
{this.Address}
%s{this.EmailAddress} %s{this.PhoneNumber}
Opening hours: %s{string<GymOpeningHours> this.GymOpeningHours}
%s{string<GymAccessOptions> this.AccessOptions}
"""
/// A human member of PureGym
[<WoofWare.Myriad.Plugins.JsonParse>]
type Member =
{
/// This member's ID. This is a fairly large number.
Id : int
/// No idea what this is - please tell me if you know!
CompoundMemberId : string
/// First name, e.g. "Patrick"
FirstName : string
/// Last name, e.g. "Stevens"
LastName : string
/// ID of the gym designated as this user's home gym. This is also the "Id" field of the appropriate Gym object.
HomeGymId : int
/// The name of the gym designated as this user's home gym. This is also the "Name" field of the appropriate
/// Gym object.
HomeGymName : string
/// This user's email address
EmailAddress : string
/// This user's gym access pin, probably 8 digits
GymAccessPin : string
/// This user's recorded date of birth
[<JsonPropertyName "dateofBirth">]
DateOfBirth : DateOnly
/// This user's phone number, human-readable
MobileNumber : string
/// This user's registered home postcode
[<JsonPropertyName "postCode">]
Postcode : string
/// E.g. "Corporate"
MembershipName : string
MembershipLevel : int
SuspendedReason : int
MemberStatus : int
}
/// Statistics for how many people are currently at a gym
[<WoofWare.Myriad.Plugins.JsonParse>]
type GymAttendance =
{
/// This appears always to be just equal to TotalPeopleInGym, but a string.
[<JsonRequired>]
Description : string
/// How many people are in the gym as of this statistics snapshot
[<JsonRequired>]
TotalPeopleInGym : int
/// How many people are in classes at the gym as of this statistics snapshot
[<JsonRequired>]
TotalPeopleInClasses : int
/// E.g. " or fewer"
TotalPeopleSuffix : string option
[<JsonRequired>]
IsApproximate : bool
/// When the query was received (I think)
AttendanceTime : DateTime
/// When the "total people in gym" snapshot was taken that is reported here
LastRefreshed : DateTime
/// When the "number of people in classes" snapshot was taken that is reported here
LastRefreshedPeopleInClasses : DateTime
/// Maximum capacity of the gym, or 0 if no listed capacity
MaximumCapacity : int
}
/// The visit statistics for a particular human to a particular gym.
/// The semantics of this class are basically unknown.
type MemberActivityThisMonth =
{
/// How many minutes, including classes, have been logged so far this month
TotalDurationMinutes : int
/// How long, in minutes, each visit has been on average this month
AverageDurationMinutes : int
/// How many visits have been made this month, excluding classes
TotalVisits : int
/// How many classes have been attended this month
TotalClasses : int
/// Whether this block of statistics is estimated rather than exact
IsEstimated : bool
/// When this data was constructed
LastRefreshed : DateTime
}
/// Don't use this type. It's public because System.Text.Json can't do private types.
[<WoofWare.Myriad.Plugins.JsonParse>]
type MemberActivityDto =
{
@@ -233,85 +125,41 @@ type MemberActivityDto =
LastRefreshed : DateTime
}
member this.ToMemberActivity () =
{
TotalDurationMinutes = this.TotalDuration
AverageDurationMinutes = this.AverageDuration
TotalVisits = this.TotalVisits
TotalClasses = this.TotalClasses
IsEstimated = this.IsEstimated
LastRefreshed = this.LastRefreshed
}
[<WoofWare.Myriad.Plugins.JsonParse>]
type SessionsAggregate =
{
/// Number of gym "activities" within some query-defined time period; presumably this is like classes?
/// It's always 0 for me.
Activities : int
/// Number of visits to the gym within some query-defined time period.
Visits : int
/// In minutes: total time spent in gym during the query-defined time period.
Duration : int
}
/// The DTO for gym info returned from the Sessions endpoint.
[<WoofWare.Myriad.Plugins.JsonParse>]
type VisitGym =
{
// Omitting Location, GymAccess, ContactInfo, TimeZone because these were all null for me
/// The PureGym ID of this gym, e.g. 19
Id : int
/// E.g. "London Oval", the canonical name of this gym
Name : string
/// For some reason this always seems to be "Blocked"
Status : string
}
/// Summary of a single visit to a gym.
[<WoofWare.Myriad.Plugins.JsonParse>]
type Visit =
{
// Omitted Name because it always was null for me
/// Whether the Duration field is estimated.
IsDurationEstimated : bool
/// When the visit began.
StartTime : DateTime
/// In minutes.
Duration : int
/// Which gym was visited
Gym : VisitGym
}
/// Human-readable non-round-trip representation.
override this.ToString () =
let startTime = this.StartTime.ToString "yyyy-MM-dd HH:mm"
$"%s{this.Gym.Name}: %s{startTime} (%i{this.Duration} minutes)"
/// Aggregate statistics for gym visits across a time period.
[<WoofWare.Myriad.Plugins.JsonParse>]
type SessionsSummary =
{
/// Aggregate stats for gym visits within the query-dependent time period.
Total : SessionsAggregate
/// Aggregate stats for gym visits "this week", whatever that means to PureGym.
ThisWeek : SessionsAggregate
}
/// Human-readable non-round-trip representation.
override this.ToString () =
$"%i{this.Total.Visits} visits, totalling %i{this.Total.Duration} minutes"
[<WoofWare.Myriad.Plugins.JsonParse>]
type Sessions =
{
Summary : SessionsSummary
Visits : Visit list
}
/// Human-readable non-round-trip representation.
override this.ToString () =
let summary = string<SessionsSummary> this.Summary
let visits = this.Visits |> Seq.map string<Visit> |> String.concat "\n"
$"%s{summary}\n%s{visits}"

View File

@@ -7,10 +7,22 @@
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarnOn>FS3559</WarnOn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Version="3.6.128" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
<SourceLinkGitHubHost Include="github.com" ContentUrl="https://raw.githubusercontent.com" />
</ItemGroup>
<!--
SourceLink doesn't support F# deterministic builds out of the box,
so tell SourceLink that our source root is going to be remapped.
-->
<Target Name="MapSourceRoot" BeforeTargets="_GenerateSourceLinkFile" Condition="'$(SourceRootMappedPathsFeatureSupported)' != 'true'">
<ItemGroup>
<SourceRoot Update="@(SourceRoot)">
<MappedPath>Z:\CheckoutRoot\WoofWare.Myriad\</MappedPath>
</SourceRoot>
</ItemGroup>
</Target>
</Project>

View File

@@ -1,34 +1,13 @@
# fsharp-arguments
# WoofWare.Myriad.Plugins
Some helpers in [Myriad](https://github.com/MoiraeSoftware/myriad/) which might be useful for someone writing an argument parser.
Some helpers in [Myriad](https://github.com/MoiraeSoftware/myriad/) which might be useful.
## `RemoveOptions`
These are currently somewhat experimental, and I personally am their primary customer.
The `RemoveOptions` generator in particular is extremely half-baked.
Takes a record like this:
```fsharp
type Foo =
{
A : int option
B : string
C : float list
}
```
and stamps out a record like this:
```fsharp
[<RequireQualifiedAccess>]
module Foo =
type Short =
{
A : int
B : string
C : float list
}
```
(This is a proof of concept. It would be better to somehow disambiguate the module name.)
Currently implemented:
* `JsonParse` (to stamp out `jsonParse : JsonNode -> 'T` methods);
* `RemoveOptions` (to strip `option` modifiers from a type).
## `JsonParse`
@@ -86,3 +65,66 @@ module JsonRecordType =
let A = node.["a"].AsValue().GetValue<int>()
{ A = A; B = B; C = C; D = D }
```
### What's the point?
`System.Text.Json`, in a `PublishAot` context, relies on C# source generators.
The default reflection-heavy implementations have the necessary code trimmed away, and result in a runtime exception.
But C# source generators [are entirely unsupported in F#](https://github.com/dotnet/fsharp/issues/14300).
This Myriad generator expects you to use `System.Text.Json` to construct a `JsonNode`, and then the generator takes over to construct a strongly-typed object.
### Limitations
This source generator is enough for what I first wanted to use it for.
However, there is *far* more that could be done.
* Make it possible to give an exact format and cultural info in date and time parsing.
* Make it possible to reject parsing if extra fields are present.
* Rather than just throwing `NullReferenceException`, print out the field name that failed.
* Generally support all the `System.Text.Json` attributes.
## `RemoveOptions`
Takes a record like this:
```fsharp
type Foo =
{
A : int option
B : string
C : float list
}
```
and stamps out a record like this:
```fsharp
[<RequireQualifiedAccess>]
module Foo =
type Short =
{
A : int
B : string
C : float list
}
```
### What's the point?
The motivating example is argument parsing.
An argument parser naturally wants to express "the user did not supply this, so I will provide a default".
But it's not a very ergonomic experience for the programmer to deal with all these options,
so this Myriad generator stamps out a type *without* any options, and also stamps out an appropriate constructor function.
### Limitations
This generator is *far* from where I want it, because I haven't really spent any time on it.
* It really wants to be able to recurse into the types within the record, to strip options from them.
* It needs some sort of attribute to mark a field as *not* receiving this treatment.
* What do we do about discriminated unions?
# Detailed examples
See the tests.
For example, [PureGymDto.fs](./ConsumePlugin/PureGymDto.fs) is a real-world set of DTOs.

View File

@@ -7,6 +7,7 @@
<Copyright>Copyright (c) Patrick Stevens 2023</Copyright>
<Description>Provides some Myriad compile-time code generation plugins.</Description>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/Smaug123/WoofWare.Myriad</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>myriad;fsharp;source-generator;source-gen;json</PackageTags>
@@ -27,6 +28,10 @@
<Compile Include="JsonParseGenerator.fs" />
<None Include="version.json" />
<EmbeddedResource Include="SurfaceBaseline.txt" />
<None Include="..\README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.Build.NoTargets/1.0.80"> <!-- This is not a project we want to build. -->
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<RestorePackagesPath>../.analyzerpackages/</RestorePackagesPath>
<TargetFramework>net6.0</TargetFramework>
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<AutomaticallyUseReferenceAssemblyPackages>false</AutomaticallyUseReferenceAssemblyPackages> <!-- We don't want to build this project, so we do not need the reference assemblies for the framework we chose.-->
</PropertyGroup>
<ItemGroup>
<PackageDownload Include="G-Research.FSharp.Analyzers" Version="[0.6.0]" />
</ItemGroup>
</Project>

View File

@@ -14,11 +14,11 @@
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
pname = "fsharp-arguments";
pname = "WoofWare.Myriad.Plugins";
dotnet-sdk = pkgs.dotnet-sdk_8;
dotnet-runtime = pkgs.dotnetCorePackages.runtime_8_0;
version = "0.1";
dotnetTool = toolName: toolVersion: sha256:
dotnetTool = dllOverride: toolName: toolVersion: sha256:
pkgs.stdenvNoCC.mkDerivation rec {
name = toolName;
version = toolVersion;
@@ -29,17 +29,23 @@
sha256 = sha256;
installPhase = ''mkdir -p $out/bin && cp -r tools/net6.0/any/* $out/bin'';
};
installPhase = ''
installPhase = let
dll =
if isNull dllOverride
then name
else dllOverride;
in ''
runHook preInstall
mkdir -p "$out/lib"
cp -r ./bin/* "$out/lib"
makeWrapper "${dotnet-runtime}/bin/dotnet" "$out/bin/${name}" --add-flags "$out/lib/${name}.dll"
makeWrapper "${dotnet-runtime}/bin/dotnet" "$out/bin/${name}" --add-flags "$out/lib/${dll}.dll"
runHook postInstall
'';
};
in {
packages = {
fantomas = dotnetTool "fantomas" (builtins.fromJSON (builtins.readFile ./.config/dotnet-tools.json)).tools.fantomas.version "sha256-Jmo7s8JMdQ8SxvNvPnryfE7n24mIgKi5cbgNwcQw3yU=";
fantomas = dotnetTool null "fantomas" (builtins.fromJSON (builtins.readFile ./.config/dotnet-tools.json)).tools.fantomas.version "sha256-Jmo7s8JMdQ8SxvNvPnryfE7n24mIgKi5cbgNwcQw3yU=";
fsharp-analyzers = dotnetTool "FSharp.Analyzers.Cli" "fsharp-analyzers" (builtins.fromJSON (builtins.readFile ./.config/dotnet-tools.json)).tools.fsharp-analyzers.version "sha256-wDS7aE4VI718iwU8xUm0aCOYIcFpMuqWu9+H5d+8XAA=";
fetchDeps = let
flags = [];
runtimeIds = ["win-x64"] ++ map (system: pkgs.dotnetCorePackages.systemToDotnetRid system) dotnet-sdk.meta.platforms;
@@ -60,7 +66,7 @@
}));
default = pkgs.buildDotnetModule {
pname = pname;
name = "argument-helpers";
name = "WoofWare.Myriad.Plugins";
version = version;
src = ./.;
projectFile = "./WoofWare.Myriad.Plugins/WoofWare.Myriad.Plugins.fsproj";

View File

@@ -1,6 +1,11 @@
# This file was automatically generated by passthru.fetch-deps.
# Please don't edit it manually, your changes might get overwritten!
{fetchNuGet}: [
(fetchNuGet {
pname = "fsharp-analyzers";
version = "0.22.0";
sha256 = "sha256-wDS7aE4VI718iwU8xUm0aCOYIcFpMuqWu9+H5d+8XAA=";
})
(fetchNuGet {
pname = "fantomas";
version = "6.3.0-alpha-005";