Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement type-based API for progress records #13

Merged
merged 19 commits into from
Feb 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ version = "0.1.0"

[deps]
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"

[compat]
julia = "0.7, 1"

[extras]
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Test"]
test = ["Test", "Random"]
309 changes: 293 additions & 16 deletions src/ProgressLogging.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,232 @@ module ProgressLogging
# Use README as the docstring of the module:
@doc read(joinpath(dirname(@__DIR__), "README.md"), String) ProgressLogging

export @progress, @withprogress, @logprogress
export @progress, @progressid, @withprogress, @logprogress

using Base.Meta: isexpr
using UUIDs: UUID, uuid4
using Logging: Logging, @logmsg, LogLevel

if VERSION >= v"1.1-"
using UUIDs: uuid5
else
import SHA
function uuid5(ns::UUID, name::String)
nsbytes = zeros(UInt8, 16)
nsv = ns.value
for idx in Base.OneTo(16)
nsbytes[idx] = nsv >> 120
nsv = nsv << 8
end
hash_result = SHA.sha1(append!(nsbytes, convert(Vector{UInt8}, codeunits(unescape_string(name)))))
# set version number to 5
hash_result[7] = (hash_result[7] & 0x0F) | (0x50)
hash_result[9] = (hash_result[9] & 0x3F) | (0x80)
v = zero(UInt128)
#use only the first 16 bytes of the SHA1 hash
for idx in Base.OneTo(16)
v = (v << 0x08) | hash_result[idx]
end
return UUID(v)
end
end

const ProgressLevel = LogLevel(-1)

"""
ProgressLogging.ROOTID

This is used as `parentid` of root [`Progress`](@ref)es.
"""
const ROOTID = UUID(0)

"""
ProgressLogging.Progress(id, [fraction]; [parentid, name, done])

# Usage: Progress log record provider

Progress log record can be created by using the following pattern

```julia
id = uuid4()
try
@info Progress(id) # create a progress bar
# some time consuming job
# ...
@info Progress(id, 0.1) # update progress to 10%
# ...
finally
@info Progress(id, done = true) # close the progress bar
end
```

It is recommended to use [`@withprogress`](@ref),
[`@logprogress`](@ref), and optionally [`@progressid`](@ref) to create
log records.

# Usage: Progress log record consumer (aka progress monitor)

It is recommended to use [`ProgressLogging.asprogress`](@ref) instead
of checking `message isa Progress`. Progress monitors can retrieve
progress-related information from the following properties.

# Properties
- `fraction::Union{Float64,Nothing}`: it can take following values:
- `0 <= fraction < 1`
- `fraction >= 1`: completed
- `fraction = nothing`: indeterminate progress
- `id::UUID`: Identifier of the job whose progress is at `fraction`.
- `parentid::UUID`: The ID of the parent progress. It is set to
[`ProgressLogging.ROOTID`](@ref) when there is no parent progress.
This is used for representing progresses of nested jobs. Note that
sub-jobs may be executed concurrently; i.e., there can be multiple
child jobs for one parent job.
- `name::String`: Name of the progress bar.
- `done::Bool`: `true` if the job is done.
"""
struct Progress
id::UUID
parentid::UUID
fraction::Union{Float64,Nothing}
name::String
done::Bool

function Progress(id, parentid, fraction, name, done)
if fraction isa Real && isnan(fraction)
fraction = nothing
end
return new(id, parentid, fraction, name, done)
end
end

Progress(;
id::UUID,
parentid::UUID = ROOTID, # not nested by default
fraction::Union{Float64,Nothing} = nothing,
name::String = "",
done::Bool = false,
) = Progress(id, parentid, fraction, name, done)

Progress(id::UUID, fraction::Union{Real,Nothing} = nothing; kwargs...) =
Progress(; kwargs..., fraction = fraction, id = id)

# Define `string`/`print` so that progress log records are (somewhat)
# readable even without specific log monitors.
function Base.print(io::IO, progress::Progress)
print(io, isempty(progress.name) ? "Progress" : progress.name)
if progress.parentid !== ROOTID
print(io, " (sub)")
end
print(io, ": ")
if progress.fraction === nothing
print(io, "??%")
else
print(io, floor(Int, progress.fraction * 100), '%')
end
return
end

const PROGRESS_LOGGING_UUID_NS = UUID("1e962757-ea70-431a-b9f6-aadf988dcb7f")

asuuid(id::UUID) = id
asuuid(id) = uuid5(PROGRESS_LOGGING_UUID_NS, repr(id))


"""
ProgressLogging.asprogress(_, name, _, _, id, _, _; progress, ...) :: Union{Progress, Nothing}

Pre-process log record to obtain a [`Progress`](@ref) object if it is
one of the supported format. This is mean to be used with the
`message` positional argument and _all_ keyword arguments passed to
`Logging.handle_message`. Example:

```julia
function Logging.handle_message(logger::MyLogger, args...; kwargs...)
progress = ProgressLogging.asprogress(args...; kwargs...)
if progress !== nothing
return # handle progress log record
end
# handle normal log record
end
```
"""
asprogress(_level, progress::Progress, _args...; _...) = progress
function asprogress(
_level,
name,
_module,
_group,
id,
_file,
_line;
progress = undef, # `undef` is an arbitrary unsupported value
kwargs...,
)
if progress isa Union{Nothing,Real,AbstractString}
return _asprogress(name, id; progress = progress, kwargs...)
else
return nothing
end
end

# `parentid` is used from `@logprogress`.
function _asprogress(name, id, parentid = ROOTID; progress, _...)
if progress isa Union{Nothing,Real}
fraction = progress
elseif progress == "done"
fraction = nothing
else
return nothing
end
return Progress(
fraction = fraction,
name = name,
id = asuuid(id),
parentid = parentid,
done = progress == "done",
)
end

# To pass `Progress` value without breaking progress monitors with the
# previous `progress` key based specification, we create a custom
# string type that has `Progress` attached to it. This is used as the
# third argument `message` of `Logging.handle_message`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever! Would the plan be to remove this and just use bare Progress in the future?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @pfitzseb wants to keep the original open/untyped API. I'm OK for keeping this as long as this compatible layer becomes annoying hard to maintain. ProgressString is somewhat already "too clever" but maybe it's within an OK range.

struct ProgressString <: AbstractString
progress::Progress
end

asprogress(_level, str::ProgressString, _args...; _...) = str.progress

# Since `Base.string(::AbstractString)` is defined to be an `identity`
# function, we overload it to make sure that `string(message)`
# typically used in the loggers converts `ProgressString` to a vanilla
# `String` as soon as possible. It may not be needed if we define
# `ProgressString` perfectly. But let's play on the safe side.
Base.string(str::ProgressString) = str.progress.name

Base.print(io::IO, str::ProgressString) = print(io, string(str))
Base.convert(::Type{ProgressString}, str::ProgressString) = str
Base.convert(::Type{T}, str::ProgressString) where {T<:AbstractString} =
convert(T, str.progress.name)

# Define `cmp` to make `==` etc. work
Base.cmp(a::AbstractString, b::ProgressString) = cmp(a, string(b))
Base.cmp(a::ProgressString, b::AbstractString) = cmp(string(a), b)
Base.cmp(a::ProgressString, b::ProgressString) = cmp(string(a), string(b))

# Avoid using `show(::IO, ::AbstractString)` which expects
# `Base.print_quoted` to work.
function Base.show(io::IO, str::ProgressString)
if get(io, :typeinfo, Any) === ProgressString
show(io, string(str))
return
end
print(io, @__MODULE__, ".")
print(io, "ProgressString(")
show(io, str.progress)
print(io, ")")
end

"""
progress(f::Function; name = "")

Expand Down Expand Up @@ -37,7 +256,7 @@ end
```
"""
function progress(f; name = "")
_id = gensym()
_id = uuid4()
@logmsg ProgressLevel name progress = NaN _id = _id
try
f(_id)
Expand All @@ -47,10 +266,11 @@ function progress(f; name = "")
end

const _id_var = gensym(:progress_id)
const _parentid_var = gensym(:progress_parentid)
const _name_var = gensym(:progress_name)

"""
@withprogress [name=""] ex
@withprogress [name=""] [parentid=uuid4()] ex

Create a lexical environment in which [`@logprogress`](@ref) can be used to
emit progress log events without manually specifying the log level, `_id`,
Expand All @@ -65,21 +285,44 @@ and name (log message).
end
```
"""
macro withprogress(ex1, ex2 = nothing)
_withprogress(ex1, ex2)
macro withprogress(exprs...)
_withprogress(exprs...)
end

_withprogress(ex, ::Nothing) = _withprogress(:(name = ""), ex)
function _withprogress(kwarg, ex)
if !(kwarg.head == :(=) && kwarg.args[1] == :name)
throw(ArgumentError("First expression to @withprogress must be `name=...`. Got: $kwarg"))
end
name = kwarg.args[2]
function _withprogress(exprs...)
length(exprs) == 0 &&
throw(ArgumentError("`@withprogress` requires at least one expression."))

m = @__MODULE__

kwargs = Dict(:name => "", :parentid => :($m.@progressid()))
unsupported = []
for kw in exprs[1:end-1]
if isexpr(kw, :(=)) && length(kw.args) == 2 && haskey(kwargs, kw.args[1])
kwargs[kw.args[1]] = kw.args[2]
else
push!(unsupported, kw)
end
end

# Error on invalid input expressions:
if !isempty(unsupported)
msg = sprint() do io
println(io, "Unsupported optional arguments:")
for kw in unsupported
println(io, kw)
end
print(io, "`@withprogress` supports only following keyword arguments: ")
join(io, keys(kwargs), ", ")
end
throw(ArgumentError(msg))
end

ex = exprs[end]
quote
let $_id_var = gensym(:progress_id),
$_name_var = $name
let $_parentid_var = $(kwargs[:parentid]),
$_id_var = $uuid4(),
$_name_var = $(kwargs[:name])
$m.@logprogress NaN
try
$ex
Expand All @@ -103,20 +346,54 @@ Optional first argument `name` can be used to change the name of the
progress bar. Additional keyword arguments are passed to `@logmsg`.
"""
macro logprogress(name, progress = nothing, args...)
name_expr = :($Base.@isdefined($_name_var) ? $_name_var : "")
if progress == nothing
# Handle: @logprogress progress
kwargs = (:(progress = $name), args...)
name = _name_var
progress = name
name = name_expr
elseif isexpr(progress, :(=)) && progress.args[1] isa Symbol
# Handle: @logprogress progress key1=val1 ...
kwargs = (:(progress = $name), progress, args...)
name = _name_var
progress = name
name = name_expr
else
# Otherwise, it's: @logprogress name progress key1=val1 ...
kwargs = (:(progress = $progress), args...)
end

id_err = "`@logprogress` must be used inside `@withprogress` or with `_id` keyword argument"
id_expr = :($Base.@isdefined($_id_var) ? $_id_var : $error($id_err))
for x in kwargs
if isexpr(x, :(=)) && x.args[1] === :_id
id_expr = :($asuuid($(x.args[2])))
# last set wins; so not `break`ing
end
end

@gensym id_tmp
# Emitting progress log record as old/open API (i.e., using
# `progress` key) and _also_ as new API based on `Progress` type.
msgexpr = :($ProgressString($_asprogress(
$name,
$id_tmp,
$_parentid_var;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I followed everything which was going on with interpolation and macro hygiene here!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I had a trouble when using a macro inside a macro. I tend to give up fighting hygiene and escape the whole block when it happens. I'll see if I can avoid this after writing tests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you can make it neater by all means that would be great.

But I agree that sometimes manual escaping is just easier 😞

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I tried to avoid interpolation but it didn't work. Here is a MWE:

julia> macro f(x)
           esc(:($Base.@isdefined $x))
       end
@f (macro with 1 method)

julia> macro g(x)
           :(@isdefined $(esc(x)))
       end
@g (macro with 1 method)

julia> let a = 1
           @f a
       end
true

julia> let a = 1
           @g a
       end
ERROR: LoadError: MethodError: no method matching @isdefined(::LineNumberNode, ::Module, ::Expr)
Closest candidates are:
  @isdefined(::LineNumberNode, ::Module, ::Symbol) at reflection.jl:265
in expression starting at REPL[26]:2

We could use Expr(:isdefined, var) (or Expr(:islocal, var)) directly like Base is doing:
JuliaLang/julia@4800158#diff-121162110854f09c3be5b39209646009L353

But I prefer sticking with the high-level API @isdefined since Expr(:isdefined, :x) is listed only at lowered AST section: https://docs.julialang.org/en/latest/devdocs/ast/#Lowered-form-1

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I prefer sticking with the high-level API @isdefined

Yes the special forms without surface syntax are implementation details of Base so best to avoid them.

I expect the error here is because nested macros see the Expr(:esc, ...) from the parent macro and @isdefined can't deal with that. It's an unsolved problem (JuliaLang/julia#23221 and all that).

progress = $progress,
)))
quote
$id_tmp = $id_expr
$Logging.@logmsg($ProgressLevel, $msgexpr, $(kwargs...), _id = $id_tmp)
end |> esc
end

"""
@progressid

Get the progress ID of current lexical scope.
"""
macro progressid()
quote
$Logging.@logmsg($ProgressLevel, $name, _id = $_id_var, $(kwargs...))
$Base.@isdefined($_id_var) ? $_id_var : $ROOTID
end |> esc
end

Expand Down
Loading