WoofWare.Myriad.Plugins
2.2.1
See the version list below for details.
dotnet add package WoofWare.Myriad.Plugins --version 2.2.1
NuGet\Install-Package WoofWare.Myriad.Plugins -Version 2.2.1
<PackageReference Include="WoofWare.Myriad.Plugins" Version="2.2.1" />
paket add WoofWare.Myriad.Plugins --version 2.2.1
#r "nuget: WoofWare.Myriad.Plugins, 2.2.1"
// Install WoofWare.Myriad.Plugins as a Cake Addin #addin nuget:?package=WoofWare.Myriad.Plugins&version=2.2.1 // Install WoofWare.Myriad.Plugins as a Cake Tool #tool nuget:?package=WoofWare.Myriad.Plugins&version=2.2.1
WoofWare.Myriad.Plugins
Some helpers in Myriad which might be useful.
Currently implemented:
JsonParse
(to stamp outjsonParse : JsonNode -> 'T
methods).JsonSerialize
(to stamp outtoJsonNode : 'T -> JsonNode
methods).HttpClient
(to stamp out a RestEase-style HTTP client).GenerateMock
(to stamp out a record type corresponding to an interface, like a compile-time Foq).ArgParser
(to stamp out a basic argument parser)CreateCatamorphism
(to stamp out a non-stack-overflowing catamorphism for a discriminated union).RemoveOptions
(to stripoption
modifiers from a type) - this one is particularly half-baked!
If you would like to ensure that your particular use-case remains unbroken, please do contribute tests to this repository.
The ConsumePlugin
assembly contains a number of invocations of these source generators,
so you just need to add copies of your types to that assembly to ensure that I will at least notice if I break the build;
and if you add tests to WoofWare.Myriad.Plugins.Test
then I will also notice if I break the runtime semantics of the generated code.
JsonParse
Takes records like this:
[<WoofWare.Myriad.Plugins.JsonParse>]
type InnerType =
{
[<JsonPropertyName "something">]
Thing : string
}
/// My whatnot
[<WoofWare.Myriad.Plugins.JsonParse>]
type JsonRecordType =
{
/// A thing!
A : int
/// Another thing!
B : string
[<System.Text.Json.Serialization.JsonPropertyName "hi">]
C : int list
D : InnerType
}
and stamps out parsing methods like this:
/// Module containing JSON parsing methods for the InnerType type
[<RequireQualifiedAccess>]
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module InnerType =
/// Parse from a JSON node.
let jsonParse (node: System.Text.Json.Nodes.JsonNode) : InnerType =
let Thing = node.["something"].AsValue().GetValue<string>()
{ Thing = Thing }
namespace UsePlugin
/// Module containing JSON parsing methods for the JsonRecordType type
[<RequireQualifiedAccess>]
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module JsonRecordType =
/// Parse from a JSON node.
let jsonParse (node: System.Text.Json.Nodes.JsonNode) : JsonRecordType =
let D = InnerType.jsonParse node.["d"]
let C =
node.["hi"].AsArray() |> Seq.map (fun elt -> elt.GetValue<int>()) |> List.ofSeq
let B = node.["b"].AsValue().GetValue<string>()
let A = node.["a"].AsValue().GetValue<int>()
{ A = A; B = B; C = C; D = D }
You can optionally supply the boolean true
to the attribute,
which will cause Myriad to stamp out an extension method rather than a module with the same name as the type.
This is useful if you want to reuse the type name as a module name yourself,
or if you want to apply multiple source generators which each want to use the module name.
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#.
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.
- Generally support all the
System.Text.Json
attributes.
For an example of using both JsonParse
and JsonSerialize
together with complex types, see the type definitions and tests.
JsonSerialize
Takes records like this:
[<WoofWare.Myriad.Plugins.JsonSerialize true>]
type InnerTypeWithBoth =
{
[<JsonPropertyName("it's-a-me")>]
Thing : string
ReadOnlyDict : IReadOnlyDictionary<string, Uri list>
}
and stamps out modules like this:
module InnerTypeWithBoth =
let toJsonNode (input : InnerTypeWithBoth) : System.Text.Json.Nodes.JsonNode =
let node = System.Text.Json.Nodes.JsonObject ()
do
node.Add (("it's-a-me"), System.Text.Json.Nodes.JsonValue.Create<string> input.Thing)
node.Add (
"ReadOnlyDict",
(fun field ->
let ret = System.Text.Json.Nodes.JsonObject ()
for (KeyValue (key, value)) in field do
ret.Add (key.ToString (), System.Text.Json.Nodes.JsonValue.Create<Uri> value)
ret
) input.Map
)
node
Also includes an opinionated serializer for discriminated unions. (Any such serializer must be opinionated, because JSON does not natively model DUs.)
As in JsonParse
, you can optionally supply the boolean true
to the attribute,
which will cause Myriad to stamp out an extension method rather than a module with the same name as the type.
The same limitations generally apply to JsonSerialize
as do to JsonParse
.
For an example of using both JsonParse
and JsonSerialize
together with complex types, see the type definitions and tests.
ArgParser
Takes a record like this:
[<ArgParser>]
type Foo =
{
[<ArgumentHelpText "Enable the frobnicator">]
SomeFlag : bool
A : int option
[<ArgumentDefaultFunction>]
B : Choice<int, int>
[<ArgumentDefaultEnvironmentVariable "MY_ENV_VAR">]
BWithEnv : Choice<int, int>
C : float list
// optionally:
[<PositionalArgs>]
Rest : string list // or e.g. `int list` if you want them parsed into a type too
}
static member DefaultB () = 4
and stamps out a basic parse
method of this signature:
[<RequireQualifiedAccess>]
module Foo =
// in case you want to test it
let parse' (getEnvVar : string -> string) (args : string list) : Foo = ...
// the one we expect you actually want to use
let parse (args : string list) : Foo = ...
Default arguments are handled as Choice<'a, 'a>
:
you get a Choice1Of2
if the user provided the input, or a Choice2Of2
if the parser filled in your specified default value.
You can control TimeSpan
and friends with the [<InvariantCulture>]
and [<ParseExact @"hh\:mm\:ss">]
attributes.
You can generate extension methods for the type, instead of a module with the type's name, using [<ArgParser (* isExtensionMethod = *) true>]
.
If --help
appears in a position where the parser is expecting a key (e.g. in the first position, or after a --foo=bar
), the parser fails with help text.
The parser also makes a limited effort to supply help text when encountering an invalid parse.
What's the point?
I got fed up of waiting for us to find time to rewrite the in-house one at work. That one has a bunch of nice compositional properties, which my version lacks: I can basically only deal with primitive types, and e.g. you can't stack records and discriminated unions inside each other.
But I do want an F#-native argument parser suitable for AOT-compilation.
Why not Argu? Answer: I got annoyed with having to construct my records by hand even after Argu returned and said the parsing was all "done".
Limitations
This is very bare-bones, but do raise GitHub issues if you like (or if you find cases where the parser does the wrong thing).
- Help is signalled by throwing an exception, so you'll get an unsightly stack trace and a nonzero exit code.
- Help doesn't take into account any arguments the user has entered. Ideally you'd get contextual information like an identification of which args the user has supplied at the point where the parse failed or help was requested.
- I don't handle very many types, and in particular a real arg parser would handle DUs and records with nesting.
- I don't try very hard to find a valid parse. It may well be possible to find a case where I fail to parse despite there existing a valid parse.
- There's no subcommand support (you'll have to do that yourself).
It should work fine if you just want to compose a few primitive types, though.
RemoveOptions
Takes a record like this:
type Foo =
{
A : int option
B : string
C : float list
}
and stamps out a record like this:
[<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?
HttpClient
Takes a type like this:
[<WoofWare.Myriad.Plugins.HttpClient>]
type IPureGymApi =
[<Get "v1/gyms/">]
abstract GetGyms : ?ct : CancellationToken -> Task<Gym list>
[<Get "v1/gyms/{gym_id}/attendance">]
abstract GetGymAttendance : [<Path "gym_id">] gymId : int * ?ct : CancellationToken -> Task<GymAttendance>
[<Get "v1/member">]
abstract GetMember : ?ct : CancellationToken -> Task<Member>
[<Get "v1/gyms/{gym_id}">]
abstract GetGym : [<Path "gym_id">] gymId : int * ?ct : CancellationToken -> Task<Gym>
[<Get "v1/member/activity">]
abstract GetMemberActivity : ?ct : CancellationToken -> Task<MemberActivityDto>
[<Get "v2/gymSessions/member">]
abstract GetSessions :
[<Query>] fromDate : DateTime * [<Query>] toDate : DateTime * ?ct : CancellationToken -> Task<Sessions>
and stamps out a type like this:
/// Module for constructing a REST client.
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
[<RequireQualifiedAccess>]
module PureGymApi =
/// Create a REST client.
let make (client : System.Net.Http.HttpClient) : IPureGymApi =
{ new IPureGymApi with
member _.GetGyms (ct : CancellationToken option) =
async {
let! ct = Async.CancellationToken
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Get,
RequestUri = System.Uri (client.BaseAddress.ToString () + "v1/gyms/")
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
let response = response.EnsureSuccessStatusCode ()
let! stream = response.Content.ReadAsStreamAsync ct |> Async.AwaitTask
let! node =
System.Text.Json.Nodes.JsonNode.ParseAsync (stream, cancellationToken = ct)
|> Async.AwaitTask
return node.AsArray () |> Seq.map (fun elt -> Gym.jsonParse elt) |> List.ofSeq
}
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
// (more methods here)
}
What's the point?
The motivating example is again ahead-of-time compilation: we wish to avoid the reflection which RestEase does.
Features
- Variable and constant header values are supported:
see the definition of
IApiWithHeaders
.
Limitations
RestEase is complex, and handles a lot of different stuff.
- If you set the
BaseAddress
on your inputHttpClient
, make sure to end with a trailing slash on any trailing directories (so"blah/foo/"
rather than"blah/foo"
). We combine URIs usingUriKind.Relative
, so without a trailing slash, the last component may be chopped off. - Parameters are serialised naively with
toJsonNode
as though theJsonSerialize
generator were applied, and you can't control the serialisation. You can't yet serialise e.g. a primitive type this way (other thanString
); all body parameters must be types which have a suitabletoJsonNode : 'a -> JsonNode
method. - Deserialisation follows the same logic as the
JsonParse
generator, and it generally assumes you're using types whichJsonParse
is applied to. - Anonymous parameters are currently forbidden.
There are also some design decisions:
- Every function must take an optional
CancellationToken
(which is good practice anyway); so arguments are forced to be tupled. - The
[<Optional>]
attribute is not supported and will probably not be supported, because I consider it to be cursed.
GenerateMock
Takes a type like this:
[<GenerateMock>]
type IPublicType =
abstract Mem1 : string * int -> string list
abstract Mem2 : string -> int
and stamps out a type like this:
/// Mock record type for an interface
type internal PublicTypeMock =
{
Mem1 : string * int -> string list
Mem2 : string -> int
}
static member Empty : PublicTypeMock =
{
Mem1 = (fun x -> raise (System.NotImplementedException "Unimplemented mock function"))
Mem2 = (fun x -> raise (System.NotImplementedException "Unimplemented mock function"))
}
interface IPublicType with
member this.Mem1 (arg0, arg1) = this.Mem1 (arg0, arg1)
member this.Mem2 (arg0) = this.Mem2 (arg0)
What's the point?
Reflective mocking libraries like Foq in my experience are a rich source of flaky tests. The Grug-brained developer would prefer to do this without reflection, and this reduces the rate of strange one-in-ten-thousand "failed to generate IL" errors. But since F# does not let you partially update an interface definition, we instead stamp out a record, thereby allowing the programmer to use F#'s record-update syntax.
Features
- You may supply an
isInternal : bool
argument to the attribute. By default, we make the resulting record type at most internal (never public), since this is intended only to be used in tests; but you can instead make it public with[<GenerateMock false>]
.
CreateCatamorphism
Takes a collection of mutually recursive discriminated unions:
[<CreateCatamorphism "MyCata">]
type Expr =
| Const of Const
| Pair of Expr * Expr * PairOpKind
| Sequential of Expr list
| Builder of Expr * ExprBuilder
and ExprBuilder =
| Child of ExprBuilder
| Parent of Expr
and stamps out a type like this:
type ExprCata<'Expr, 'ExprBuilder> =
abstract Const : Const -> 'Expr
abstract Pair : 'Expr -> 'Expr -> PairOpKind -> 'Expr
abstract Sequential : 'Expr list -> 'Expr
abstract Builder : 'Expr -> 'ExprBuilder -> 'Expr
type ExprBuilderCata<'Expr, 'ExprBuilder> =
abstract Child : 'ExprBuilder -> 'ExprBuilder
abstract Parent : 'Expr -> 'ExprBuilder
type MyCata<'Expr, 'ExprBuilder> =
{
Expr : ExprCata<'Expr, 'ExprBuilder>
ExprBuilder : ExprBuilderCata<'Expr, 'ExprBuilder>
}
[<RequireQualifiedAccess>]
module ExprCata =
let runExpr (cata : MyCata<'ExprRet, 'ExprBuilderRet>) (x : Expr) : 'ExprRet =
failwith "this is implemented"
let runExprBuilder (cata : MyCata<'ExprRet, 'ExprBuilderRet>) (x : ExprBuilder) : 'ExprBuilderRet =
failwith "this is implemented"
What's the point?
Recursing over a tree is not easy to get right, especially if you want to avoid stack overflows. Instead of writing the recursion many times, it's better to do it once, and then each time you only plug in what you want to do.
Features
- Mutually recursive DUs are supported (as in the example above).
Every DU in a recursive
type Foo... and Bar...
knot will be given an appropriate cata, as long as any one of those DUs has the[<CreateCatamorphism>]
attribute. - There is limited support for records and for lists.
- There is extremely brittle support for generics in the DUs you are cata'ing over.
It is based on the names of the generic parameters, so you must ensure that generic parameters with the same name have the same meaning across the various cases in your recursive knot of DUs.
(If you overstep the bounds of what this generator can do, you will get compile-time errors, e.g. with generics being constrained to each other's values.)
See the List tests for an example, where we re-implement
FSharpList<'a>
.
Limitations
I am not at all convinced of the correctness of this generator, and I know it is very incomplete (in the sense that there are many possible DUs you could write for which the generator will bail out). I strongly recommend implementing the identity catamorphism for your type and using property-based tests (as I do) to assert that the correct thing happens. Feel free to raise GitHub issues with code I can copy-paste to reproduce a case where the wrong thing happens (though I can't promise to look at them).
- This is a particularly half-baked generator which has so far seen no real-world use. It likely has a bunch of 80/20 low-hanging fruit remaining, but it also likely has impossible problems to solve which I don't know about yet.
- Only a very few kinds of DU field are currently implemented. For example, this generator can't see through an interface (e.g. the kind of interface one would use to implement the crate pattern to represent a GADT), so the generated cata will simply grant you access to the interface (rather than attempting to descend into it to discover recursive references). You can't nest lists deeply. All sorts of other cases are unaddressed.
- This generator does not try to solve the "exponential diamond dependency" problem.
If you have a case of the form
type Expr = | Branch of Expr * Expr
, the cata will walk into bothExpr
s separately. If theExpr
s happen to be equal, the cata will nevertheless traverse them individually (that is, it will traverse the sameExpr
twice). Your type may represent a DAG, but we will always effectively expand it into a tree of paths and operate on each of the exponentially-many paths.
Detailed examples
See the tests. For example, PureGymDto.fs is a real-world set of DTOs.
How to use
- In your
.fsproj
file, define a helper variable so that subsequent steps don't all have to be kept in sync:<PropertyGroup> <WoofWareMyriadPluginVersion>2.0.1</WoofWareMyriadPluginVersion> </PropertyGroup>
- Take a reference on
WoofWare.Myriad.Plugins.Attributes
(which has no other dependencies), to obtain access to the attributes which the generator will recognise:<ItemGroup> <PackageReference Include="WoofWare.Myriad.Plugins.Attributes" Version="2.0.2" /> </ItemGroup>
- Take a reference (with private assets, to prevent these from propagating to your own assembly) on
WoofWare.Myriad.Plugins
, to obtain the plugins which Myriad will run, and onMyriad.Sdk
, to obtain the Myriad binary itself:<ItemGroup> <PackageReference Include="WoofWare.Myriad.Plugins" Version="$(WoofWareMyriadPluginVersion)" PrivateAssets="all" /> <PackageReference Include="Myriad.Sdk" Version="0.8.3" PrivateAssets="all" /> </ItemGroup>
- Point Myriad to the DLL within the NuGet package which is the source of the plugins:
<ItemGroup> <MyriadSdkGenerator Include="$(NuGetPackageRoot)/woofware.myriad.plugins/$(WoofWareMyriadPluginVersion)/lib/net6.0/WoofWare.Myriad.Plugins.dll" /> </ItemGroup>
Now you are ready to start using the generators.
For example, this specifies that Myriad is to use the contents of Client.fs
to generate the file GeneratedClient.fs
:
<ItemGroup>
<Compile Include="Client.fs" />
<Compile Include="GeneratedClient.fs">
<MyriadFile>Client.fs</MyriadFile>
</Compile>
</ItemGroup>
Myriad Gotchas
- MsBuild doesn't always realise that it needs to invoke Myriad during rebuild.
You can always save a whitespace change to the source file (e.g.
Client.fs
above), and MsBuild will then execute Myriad during the next build. - Fantomas, the F# source formatter which powers Myriad, is customisable with editorconfig, but it does not easily expose this customisation except through the standalone Fantomas client. So Myriad's output is formatted without respect to any conventions which may hold in the rest of your repository. You should probably add these files to your fantomasignore if you use Fantomas to format your repo; the alternative is to manually reformat every time Myriad changes the generated files.
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. |
-
net6.0
- Myriad.Core (>= 0.8.3)
- WoofWare.Myriad.Plugins.Attributes (>= 3.2.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Version | Downloads | Last updated |
---|---|---|
4.0.7 | 224 | 10/21/2024 |
4.0.6 | 312 | 10/14/2024 |
4.0.5 | 135 | 10/7/2024 |
4.0.4 | 86 | 10/6/2024 |
4.0.3 | 73 | 10/6/2024 |
4.0.2 | 73 | 10/6/2024 |
4.0.1 | 72 | 10/6/2024 |
3.1.4 | 82 | 10/3/2024 |
3.1.3 | 84 | 10/2/2024 |
3.1.2 | 77 | 10/2/2024 |
3.1.1 | 350 | 9/19/2024 |
3.0.2 | 85 | 9/19/2024 |
3.0.1 | 189 | 9/15/2024 |
2.3.10 | 116 | 9/15/2024 |
2.3.9 | 117 | 9/15/2024 |
2.3.8 | 118 | 9/14/2024 |
2.3.7 | 112 | 9/13/2024 |
2.3.6 | 131 | 9/12/2024 |
2.3.5 | 101 | 9/11/2024 |
2.3.4 | 127 | 9/10/2024 |
2.3.3 | 102 | 9/7/2024 |
2.3.2 | 117 | 9/5/2024 |
2.3.1 | 119 | 9/4/2024 |
2.2.7 | 111 | 9/4/2024 |
2.2.6 | 113 | 9/4/2024 |
2.2.5 | 112 | 9/4/2024 |
2.2.4 | 119 | 9/4/2024 |
2.2.3 | 119 | 9/3/2024 |
2.2.2 | 101 | 9/2/2024 |
2.2.1 | 388 | 8/26/2024 |
2.1.58 | 136 | 8/25/2024 |
2.1.57 | 231 | 8/13/2024 |
2.1.56 | 203 | 8/12/2024 |
2.1.55 | 91 | 8/4/2024 |
2.1.54 | 85 | 8/4/2024 |
2.1.53 | 326 | 7/8/2024 |
2.1.52 | 332 | 7/1/2024 |
2.1.51 | 129 | 7/1/2024 |
2.1.50 | 111 | 7/1/2024 |
2.1.49 | 124 | 6/27/2024 |
2.1.48 | 109 | 6/27/2024 |
2.1.47 | 123 | 6/24/2024 |
2.1.46 | 118 | 6/24/2024 |
2.1.45 | 499 | 6/17/2024 |
2.1.44 | 409 | 6/15/2024 |
2.1.43 | 100 | 6/10/2024 |
2.1.42 | 253 | 6/10/2024 |
2.1.41 | 99 | 6/9/2024 |
2.1.40 | 456 | 6/4/2024 |
2.1.39 | 102 | 6/3/2024 |
2.1.38 | 114 | 6/1/2024 |
2.1.37 | 88 | 5/31/2024 |
2.1.36 | 99 | 5/31/2024 |
2.1.35 | 103 | 5/31/2024 |
2.1.34 | 111 | 5/30/2024 |
2.1.33 | 107 | 5/30/2024 |
2.1.32 | 106 | 5/30/2024 |
2.1.31 | 117 | 5/30/2024 |
2.1.30 | 104 | 5/30/2024 |
2.1.29 | 107 | 5/28/2024 |
2.1.28 | 99 | 5/27/2024 |
2.1.27 | 100 | 5/24/2024 |
2.1.26 | 102 | 5/24/2024 |
2.1.25 | 99 | 5/24/2024 |
2.1.24 | 115 | 5/20/2024 |
2.1.23 | 101 | 5/20/2024 |
2.1.22 | 131 | 5/6/2024 |
2.1.21 | 106 | 4/30/2024 |
2.1.20 | 117 | 4/29/2024 |
2.1.19 | 106 | 4/29/2024 |
2.1.18 | 110 | 4/22/2024 |
2.1.17 | 107 | 4/17/2024 |
2.1.16 | 106 | 4/16/2024 |
2.1.15 | 112 | 4/16/2024 |
2.1.14 | 112 | 4/15/2024 |
2.1.13 | 128 | 3/19/2024 |
2.1.12 | 117 | 3/11/2024 |
2.1.11 | 111 | 3/4/2024 |
2.1.10 | 132 | 2/26/2024 |
2.1.9 | 126 | 2/26/2024 |
2.1.8 | 118 | 2/25/2024 |
2.1.7 | 122 | 2/25/2024 |
2.1.6 | 114 | 2/25/2024 |
2.1.5 | 108 | 2/19/2024 |
2.1.4 | 112 | 2/19/2024 |
2.1.3 | 104 | 2/18/2024 |
2.1.2 | 100 | 2/18/2024 |
2.1.1 | 109 | 2/17/2024 |
2.0.9 | 107 | 2/14/2024 |
2.0.8 | 121 | 2/13/2024 |
2.0.7 | 108 | 2/13/2024 |
2.0.6 | 108 | 2/13/2024 |
2.0.5 | 126 | 2/12/2024 |
2.0.4 | 112 | 2/7/2024 |
2.0.3 | 97 | 2/7/2024 |
2.0.2 | 99 | 2/7/2024 |
2.0.1 | 118 | 2/7/2024 |
1.4.15 | 119 | 2/6/2024 |
1.4.14 | 117 | 2/6/2024 |
1.4.13 | 99 | 2/6/2024 |
1.4.12 | 104 | 2/6/2024 |
1.4.11 | 123 | 2/6/2024 |
1.4.10 | 99 | 2/5/2024 |
1.4.9 | 109 | 1/30/2024 |
1.4.8 | 120 | 1/29/2024 |
1.4.7 | 101 | 1/29/2024 |
1.4.6 | 103 | 1/29/2024 |
1.4.5 | 105 | 1/28/2024 |
1.4.4 | 91 | 1/28/2024 |
1.4.3 | 111 | 1/26/2024 |
1.4.2 | 107 | 1/26/2024 |
1.4.1 | 89 | 1/26/2024 |
1.3.5 | 93 | 1/25/2024 |
1.3.4 | 122 | 1/15/2024 |
1.3.3 | 114 | 1/15/2024 |
1.3.2 | 128 | 1/8/2024 |
1.3.1 | 124 | 1/8/2024 |
1.2.3 | 124 | 1/3/2024 |
1.2.2 | 126 | 12/31/2023 |
1.2.1 | 131 | 12/30/2023 |
1.1.15 | 139 | 12/30/2023 |
1.1.14 | 140 | 12/30/2023 |
1.1.13 | 136 | 12/30/2023 |
1.1.12 | 132 | 12/30/2023 |
1.1.11 | 119 | 12/30/2023 |
1.1.10 | 140 | 12/29/2023 |
1.1.9 | 135 | 12/29/2023 |
1.1.8 | 134 | 12/29/2023 |
1.1.7 | 139 | 12/29/2023 |
1.1.6 | 144 | 12/29/2023 |
1.1.5 | 138 | 12/29/2023 |
1.1.4 | 132 | 12/29/2023 |
1.1.3 | 120 | 12/29/2023 |
1.1.2 | 124 | 12/28/2023 |
1.1.1 | 124 | 12/28/2023 |
1.0.6 | 126 | 12/28/2023 |
1.0.5 | 125 | 12/28/2023 |
1.0.4 | 128 | 12/27/2023 |