Giter VIP home page Giter VIP logo

phew's Introduction

phew! the Pico (or Python) HTTP Endpoint Wrangler

A small webserver and templating library specifically designed for MicroPython on the Pico W. It aims to provide a complete toolkit for easily creating high quality web based interfaces for your projects.

phew! is ideal for creating web based provisioning interfaces for connected projects using the Raspberry Pi Pico W.

What phew! does:

  • a basic web server
  • optimised for speed (at import and during execution)
  • minimal use of memory
  • parameterised routing rules /greet/<name>
  • templating engine that allows inline python expressions {{name.lower()}}
  • GET, POST request methods
  • query string decoding and parsing
  • catchall handler for unrouted requests
  • multipart/form-data, x-www-form-urlencoded, and JSON POST bodies
  • string, byte, or generator based responses
  • connect_to_wifi and access_point convenience methods

Where possible phew! tries to minimise the amount of code and setup that you, the developer, has to do in favour of picking sane defaults and hiding away bits of minutiae that rarely needs to be tweaked.

How to use

phew! can be installed using pip from the command line or from your favourite IDE. In Thonny this can be achieved by clicking Tools -> Manage packages and searching for micropython-phew.

Basic example

An example web server that returns a random number between 1 and 100 (or optionally the range specified by the callee) when requested:

from phew import server, connect_to_wifi

connect_to_wifi("<ssid>", "<password>")

@server.route("/random", methods=["GET"])
def random_number(request):
  import random
  min = int(request.query.get("min", 0))
  max = int(request.query.get("max", 100))
  return str(random.randint(min, max))

@server.catchall()
def catchall(request):
  return "Not found", 404

server.run()

phew is designed specifically with performance and minimal resource use in mind. Generally this means it will prioritise doing as little work as possible including assuming the correctness of incoming requests.


Function reference

server module

The server module provides all functionality for running a web server with route handlers.

add_route

server.add_route(path, handler, methods=["GET"])

Adds a new route into the routing table. When an incoming request is received the server checks each route to find the most specific one that matches the request based on the path and method. If a route is found then the handler function is called with a request parameter that contains details about the request.

def my_handler(request):
  return "I got it!", 200

server.add_route("/testpath", my_handler, methods=["GET"])

Or, alternatively, using a decorator:

@server.route("/testpath", methods=["GET"])
def my_handler(request):
  return "I got it!", 200

set_catchall

server.set_catchall(handler)

Provide a catchall method for requests that didn't match a route.

def my_catchall(request):
  return "No matching route", 404

server.set_catchall(my_catchall)

Or, alternatively, using a decorator:

@server.catchall()
def my_catchall(request):
  return "No matching route", 404

run

server.run(host="0.0.0.0", port=80)

Starts up the web server and begins handling incoming requests.

server.run()

Types

Request

The Request object contains all of the information that was parsed out of the incoming request including form data, query string parameters, HTTP method, path, and more.

Handler functions provided to add_route and set_catchall will recieve a Request object as their first parameter.

member example type description
protocol "HTTP/1.1" string protocol version
method "GET" or "POST" string HTTP method used for this request
uri "/path/to/page?parameter=foo" string full URI of the request
path "/path/to/page" string just the path part of the URI
query_string "parameter=foo" string just the query string part of the URI
form {"foo": "bar", "name": "geoff"} dict POST body parsed as multipart/form-data
data [{"name": "jenny"}, {"name": "geoff"}] any POST body parsed as JSON
query {"parameter": "foo"} dict result of parsing the query string

At the time your route handler is being called the request has been fully parsed and you can access any properties that are relevant to the request (e.g. the form dictionary for a multipart/form-data request) any irrelevant properties will be set to None.

@server.route("/login", ["POST"])
def login_form(request):
  username = request.form.get("username", None)
  password = request.form.get("password", None)

  # check the user credentials with your own code
  # for example: 
  # 
  # logged_in = authenticate_user(username, password)

  if not logged_in:
    return "Username or password not recognised", 401

  return "Logged in!", 200

Response

The Response object encapsulates all of the attributes of your programs response to an incoming request. This include the status code of the result (e.g. 200 OK!) , the data to return, and any associated headers.

Handler functions can create and return a Response object explicitly or use a couple of shorthand forms to avoid writing the boilerplate needed.

member example type description
status 200 int HTTP status code
headers {"Content-Type": "text/html"} dict dictionary of headers to return
body "this is the response body" string or generator the content to be returned
@server.route("/greeting/<name>", ["GET"])
def user_details(request):
  return Response(f"Hello, {name}", status=200, {"Content-Type": "text/html"})
Shorthand

As shorthand instead of returning a Response object the handle may also return a tuple with between one and three values:

  • body - either a string or generator method
  • status code - defaults to 200 if not provided
  • headers - defaults to {"Content-Type": "text/html"} if not provided

For example:

@server.route("/greeting/<name>", ["GET"])
def user_details(request, name):
  return f"Hello, {name}", 200

Templates

A web server isn't much use without something to serve. While it's straightforward to serve the contents of a file or some generated JSON things get more complicated when we want to present a dynamically generated web page to the user.

phew! provides a templating engine which allows you to write normal HTML with fragments of Python code embedded to output variable values, parse input, or dynamically load assets.

render_template

render_template(template, param1="foo", param2="bar", ...):

The render_template method takes a path to a template file on the filesystem and a list of named paramaters which will be passed into the template when parsing.

The method is a generator which yields the parsing result in chunks, minimising the amount of memory used to hold the results as they can be streamed directly out rather than having to build the entire result as a string first.

Generally you will call render_template to create the body of a Response in one of your handler methods.

Template expressions

Templates are not much use if you can't inject dynamic data into them. With phew! you can embed Python expressions with {{<expression here>}} which will be evaluated during parsing.

Variables

In the simplest form you can embed a simple value by just enclosing it in double curly braces. It's also possible to perform more complicated transformations using any built in Python method.

  <div id="name">{{name}}</div>

  <div id="name">{{name.upper()}}</div>
  
  <div id="name">{{"/".join(name.split(" "))}}</div>
Conditional display

If you want to show a value only if some other condition is met then you can use the (slightly clunky) Python tenary operator.

<div>
  You won
  {{"1st" if prize == 1 else ""}}
  {{"2nd" if prize == 2 else ""}}
  {{"3rd" if prize == 3 else ""}}
  prize!
</div>

or

<div>
  You won
  {{["1st", "2nd", "3rd"][prize]}}
  prize!
</div>

While a bit unwieldy this methods works. An alternative would be to select the appropriate value in your handler and simply pass it into the template as a parameter however that would mean having some of your copy embedded into your Python code rather than all of it in one place in the template file.

Includes

You can include another template by calling render_template() again within your outer template.

include.html

Hello there {{name}}!

main.html

<!DOCTYPE html>
<body>
  {{render_template("include.html", name=name)}}
</body>

⚠️ Note: you need to explicitly pass through template parameters into the included template - they are not available by default.

logging module

log(level, text)

Add a new entry into the log file.

log("info", "> i'd like to take a minute, just sit right there")
log("error", "> the license plate said 'Fresh' and it had dice in the mirror")

The entry will automatically have the current date and time, the level value, and the amount of free memory in kB prepended:

2022-09-04 15:29:20 [debug    / 110kB] > performing startup
2022-09-04 15:30:42 [info     / 113kB]   - wake reason: rtc_alarm
2022-09-04 15:30:42 [debug    / 112kB]   - turn on activity led
2022-09-04 15:30:43 [info     / 102kB] > running pump 1 for 0.4 second
2022-09-04 15:30:46 [info     / 110kB] > 5 cache files need uploading
2022-09-04 15:30:46 [info     / 107kB] > connecting to wifi network 'yourssid'
2022-09-04 15:30:48 [debug    / 100kB]   - connecting
2022-09-04 15:30:51 [info     /  87kB]   - ip address:  192.168.x.x
2022-09-04 15:30:57 [info     /  79kB]   - uploaded 2022-09-04T15:19:03Z.json 2022-09-04 15:31:01 [info     /  82kB]   - uploaded 2022-09-04T15:28:17Z.json 2022-09-04 15:31:06 [info     /  88kB]   - uploaded 2022-09-04T15:30:43Z.json 2022-09-04 15:31:11 [info     /  95kB]   - uploaded 2022-09-04T15:29:00Z.json 2022-09-04 15:31:16 [info     / 100kB]   - uploaded 2022-09-04T15:29:21Z.json 2022-09-04 15:31:16 [info     /  98kB] > going to sleep

debug(*items)

Shorthand method for writing debug messages to the log.

warn("> this is a story")

info(*items)

Shorthand method for writing information to the log.

num = 123
info("> all about how", num, time.time())

warn(*items)

Shorthand method for writing warnings to the log.

warn("> my life got flipped")

error(*items)

Shorthand method for writing errors to the log.

warn("> turned upside down")

set_truncate_thresholds(truncate_at, truncate_to)

Will automatically truncate the log file to truncate_to bytes long when it reaches truncate_at bytes in length.

# automatically truncate when we're closed to the 
# filesystem block size to keep to a single block
set_truncate_thresholds(3.5 * 1024, 2 * 1.024)

Truncation always happens on the nearest line ending boundary so the truncated file may not exactly match the size specified.

dns module

To make implementing device provisioning interfaces (via captive portal) simple phew! provides a catchall DNS server.

If you put the Pico W into access point mode and then run the catchall DNS server it will route all DNS requests back to the local device so that they can be handled.

run_catchall

dns.run_catchall(ip_address)

Pass in the IP address of your device once in access point mode.

Helper functions

connect_to_wifi

connect_to_wifi(ssid, password, timeout=30)

Connects to the network specified by ssid with the provided password.

Returns the device IP address on success or None on failure.

access_point

access_point(ssid, password=None)

Create an access point with the specified SSID. Optionally password protected if provided.

is_connected_to_wifi

result = is_connected_to_wifi()

Returns True if there is an active WiFi connection.

get_ip_address

get_ip_address()

Returns the current IP address if connected to a network or acting as an access point or None otherwise.

Other Resources

Here are some Phew! community projects and guides that you might find useful. Note that code at the links below has not been tested by us and we're not able to offer support with it.

phew's People

Contributors

aarnas avatar gadgetoid avatar helgibbons avatar lowfatcode avatar riaanvddool avatar simonprickett avatar waveform80 avatar zodiusinfuser avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

phew's Issues

Phew._handle_request does not ensure content-length of data read

When content-type is application/x-www-form-urlencoded, the current implementation makes a single call to read:

 if request.headers["content-type"].startswith("application/x-www-form-urlencoded"):
        form_data = await reader.read(int(request.headers["content-length"]))
        request.form = _parse_query_string(form_data.decode())

Unfortunately, this doesn't always read all content-length bytes. It's necessary to loop until all bytes are read e.g.

  if request.headers["content-type"].startswith("application/x-www-form-urlencoded"):
    form_data = b""
    content_length = int(request.headers["content-length"])
    while content_length > 0:
        data = await reader.read(content_length)
        if len(data) == 0:
          break
        content_length -= len(data)
        form_data += data
    request.form = _parse_query_string(form_data.decode())

I was posting a textarea with about 1400 bytes. It was only reading about 250.

Other parts of the implementation may need to be checked to ensure that no other similar issue is present.

v 0.0.3 not available in Thonny package manager or UPIP

Trying to work with 0.0.3 to get access point support but both methods (UPIP and Thonny) only give me 0.0.2

Does 0.0.3 need to be "pushed" to these services or is there a way for me to request this version specifically?

log truncation does not work

Love the module, but after two days the file system on my Pi Pico W was full. /log.txt was taking up quite some storage:

>>> os.stat('/log.txt')
(32768, 0, 0, 0, 0, 0, 788944, 1609466686, 1609466686, 1609466686)

(that's 800kB I believe)

It contains a line once for every REST request iphew received:

2021-01-01 02:03:45 [info / 131200] > GET /metrics (200 OK) [293ms]
2021-01-01 02:04:00 [info / 125104] > GET /metrics (200 OK) [293ms]
2021-01-01 02:04:16 [info / 119008] > GET /metrics (200 OK) [293ms]
2021-01-01 02:04:31 [info / 112912] > GET /metrics (200 OK) [495ms]
2021-01-01 02:04:46 [info / 106816] > GET /metrics (200 OK) [428ms]

My program: https://github.com/SoongJr/pi-pico/blob/7046ca962b6d07bbda1c9029d5355877b9df69f0/main.py

I looked at the phew code and it clearly states "the defaults values are designed to limit the log to at most three blocks on the Pico". The code looks to me like it's trying to truncate at 11kB, which my file exceeds, so it's not working.

I have to go to work now, will look into this further in the evening, but maybe someone has an idea.

GET info from a form ?

I'm trying the example main.py with the method login (slightly modified) :

@server.route("/login", methods=["GET", "POST"])
def login_form(request):
    username = request.form.get("username", None)
    password = request.form.get("password", None)

    if username != "tom" and password != "123" :
        logged_in = False
  
    if not logged_in:
        return "Username or password not recognised", 401
    else :
        return "Logged in!", 200

There is no form showing up, just the message "Username or password not recognised"

Obviously I missing something ! Can you help please ?

Html with images

Hello,
I am a beginner with programming but seems to me that there is no way to put image files in the html?
I tried this but it's not working:
@app.route('/images/<fn>')
async def images(req, resp, fn):
return render_template('/images/{}'.format(fn),
content_type='image/jpeg')

I was wondering if at this point there's just no way to implement it?
Thanks!

is it possible looping in templates?

Hello
I'm building system based on Phew and I would like to know, is there a way to render page using some kind of looping

What i do have

data = [
{"name": "Jack", "url": "http://exaple.com"},
{"name": "Johan", "url": "http://blog.johan.com"},
{"name": "Marry", "url": "http://facebook.com/marry"},
... etc
]

Is there a way dynamically build list of links with predefined template?

<ul>
{{loop data}}
    <li><a href="{{url}}" class="person">Page for {{name}}</a></li>
{{endloop}}
</ul>

This is just a presentation of my idea what I want to achieve. Keep in mind that array of data is not fixed length and I cannot prepare static number of li tags.

Disable logging?

I dont want to have logging in my server, is ther any way to do that?

Minor suggestion: connect_to_wifi() should probably return the wlan object, rather than ip address.

This is just something I've been running into lately, in terms of usability.. There tends to be a lot of moments where I need a quick reference to the full wlan object, for access to various config options, and I've found the quickest solution was to just modify the connect_to_wifi function so it returns the full thing. Otherwise I end up not using connect_to_wifi, and miss out on the already setup loggin. heh
Edit: Just noticed one of the branches added a get_ip_address function for similar reasons. I prefer returning the whole object, so just gonna make my own fork, since I've done a lot of error handling too..

Weird issue with trying to connect to the Pico W web server.

Thanks very much for this library! It’s simplified the initial setup dramatically.

Finding it’s really hit or miss if devices at home are able to connect to the simple random number generator website.

I’ll post the combinations tomorrow, but I’m finding some IOS devices connect fine with Safari, others only work with Chrome. Bizarre. My window laptop won’t connect at all. Can’t figure out where the connection is actually failing. Web server not showing any connection attempt when it fails.

Logging Truncate not Working

Hi, I am trying to truncate log.txt by using logging.truncate (120) as in the the Kevin McLeer video. I get "TypeError: function takes 2 positional arguments but 1 were given" when I run the code.

Can anyone help please

Change location of log file

Hello, is there any way to change the file location of the log file?
I'm aware that you can modify the variable in logging.py, but is there any way to do this without changing the module's code?

Thanks!

Error: need more than 0 values to unpack

I get the error mentioned in the title after a while running main.py with the following code:

import machine
import time
from phew import server, connect_to_wifi
from phew.template import render_template

machine.Pin(13, machine.Pin.IN)
machine.Pin(18, machine.Pin.IN)

connect_to_wifi("SSID", "PASSWORD")

@server.route("/passagem", methods=["GET", "POST"])
def passagem(request):

post = request.data.get("value", None)
password = request.form.get("password", None)

if(post=="door1"):
machine.Pin(18, machine.Pin.OUT)
time.sleep(1)
machine.Pin(18, machine.Pin.IN)

elif(post=="door2"):
machine.Pin(13, machine.Pin.OUT)
time.sleep(1)
machine.Pin(13, machine.Pin.IN)

if(password == "password"):
return render_template("index.html"), 200

else:
return render_template("login.html"), 200

@server.route("/favicon.png", methods=["GET"])
def favicon(request):
return render_template("favicon.png"), 200

@server.catchall()
def catchall(request):
return "Not found", 404

server.run()

can't handle http request

2022-12-28 14:19:44 [debug / 107kB] Get request
Task exception wasn't retrieved
future: coro= <generator object '_handle_request' at 20016650>
Traceback (most recent call last):
File "uasyncio/core.py", line 1, in run_until_complete
File "/lib/phew/server.py", line 298, in _handle_request
File "/lib/phew/template.py", line 7, in render_template
OSError: [Errno 2] ENOENT

Make NTP server configurable

Currently NTP is hardcoded to pool.ntp.org which means that whenever the clock needs to be resynced it involves accessing a service outside of the local network.

For instances where either the internet is not always accessible, or firewalled, some environments have their own ntp servers. Others might use a different ntp pool.

So a means of enabling an alternate ntp server other than the hardcoded one would be useful.

Issues with Phew logfile truncation on Enviro Grow

After a number of days uptime Enviro Grow boards stop responding until log.txt and log.txt.tmp are delated - they stop logging and cannot even be put into provisioning mode again. On checking via a connected laptop the error appears to be in the Phew logging.py truncate routine - the following appears when trying to run main.py;

Traceback (most recent call last):
File "", line 28, in
File "enviro/init.py", line 159, in startup
File "phew/phew/logging.py", line 37, in truncate
NameError: local variable referenced before assignment

Once log.txt and log.txt.tmp are removed main.py can be started.

(Logged here rather than in enviro repo as issue appears to be Phew related - apologies if incorrect location!)

How (where) to close the server?

I want to retrieve some information through a web server and then close it, but I don't really understand how am I supposed to do that. server.run() is blocking, so no code past this call ever executes. The only place I see where I could call server.close() is the request handler function, but that seems ugly because then it can't return a response

Server module - Response isnt always created, leading to an error (NoneType)

There is a bug where the Server Module is checking all the scenarios, and if response is still set to None, the line writing out what the response.status is will throw an error (Line 277 in server.py)

# write status line
  status_message = status_message_map.get(**response.status**, "Unknown")

either need to check if response is still None (if response is not None) or set it to a default value.

There is another line further down 282 that will also fail - for key, value in response.headers.items():

Download a file?

How do I download a file?
I want to show all files in .html
When use press one file, web server should be able to send that file so user can download.

i can only stream the log files and other files. But i need option to download files too.

Any help?
thanks

Change http status code based on gpio input

Apologies if I'm being a bit dense but I didn't see anything in the documentation about that.

I'm working on a small project to "upgrade" my electric bike with smart RP2040 powered lighting. My intention is to have a Pico W attatched to the main front light and another attatched to the back which would host a rear light and "indicators")

I want to avoid running wires from the back of the bike to the front so I was thinking to use Phew! on the "front" Pico with a couple of buttons and the back Pico to read the status of the Phew! web server via a http request. What I'm struggling to understand is how I can listen for input on the buttons and then change the response on the webserver. I was thinking of just using http status codes to indicate the different "states".

psedocode:

if button_a == pressed

     route_a_status_code = 200

else:

    route_a_status_code = 404  

Does this make any sense at all or am I being a fruitloop?

Support for multiple web apps in one deployment

Hi,

I have an application that has two web apps. One for the initial AP configuration of the device and the second for the ongoing maintenance. Unfortunately, with phew the decorators store the routes in a global.

Phew could support this capability if the routes were stored as instance variables of a Phew server class. You'll see the similarity to Flask. The following tweak of the basic example demonstrates the idea:

from phew import server, connect_to_wifi

connect_to_wifi("<ssid>", "<password>")

webapp = server.Phew()

@webapp.route("/random", methods=["GET"])
def random_number(request):
  import random
  min = int(request.query.get("min", 0))
  max = int(request.query.get("max", 100))
  return str(random.randint(min, max))

@webapp.catchall()
def catchall(request):
  return "Not found", 404

webapp.run()

It would still be possible to not break existing apps by using a default instance of Phew in the case that the existing methods are called. In the implementation the server.route method etc. would apply to a default Phew instance stored in a server.py global variable.

Need to allow white space after the {{ and before the }}

I built a couple of templates using the {{ render_template("header.html") }} and it ignored them because of the whitespace before and after the render_template() call. I guessed this was the case and corrected my code to remove the whitespace: {{render_template("header.html")}}.

It would be nice to be more tolerant of the white space - we could add a .strip() function to enable this.

Specify static ip when connecting to wifi

Previous to phew I used to create a secondary interface with

sta_if = network.WLAN(network.STA_IF)
sta_if.ifconfig((ip, mask, gw, dns))

then create a socket and bind to that ip.
Is it possible to achieve the same behaviour in phew?

In my use case, I connect to a mobile hotspot and I want the last octet of the ip to be manually specified.

Faster implementation for small requests and responses

If the request and/or response are small (say <1kB) then it would be possible to implement a faster path where they are read and written in whole rather than line by line - is there a lot of StreamReader/StreamWriter overhead?

Task exception wasn't retrieved future: <Task> coro= <generator object '_handle_request' at 20015230> Traceback (most recent call last): File "asyncio/core.py", line 1, in run_until_complete File "/lib/phew/server.py", line 277, in _handle_request AttributeError: 'NoneType' object has no attribute 'status'

I have a problem with the following code running on a raspberry pico with the image RPI_PICO_W-20240602-v1.23.0.uf2

`from phew import connect_to_wifi, server
from machine import Pin, PWM
import json

import network_cred
ip = connect_to_wifi(network_cred.SSID, network_cred.PASSWORD)
print("Connected to IP", ip)

.....

@server.route("/api/control-led", methods=["POST"])
def ledCommand(request):

set_pwm_rgb(pwm_red, request.data["ledRed"])
set_pwm_rgb(pwm_green, request.data["ledGreen"])
set_pwm_rgb(pwm_blue, request.data["ledBlue"])
return json.dumps({"message": "Command sent successfully!"}), 200, {"Content-Type": "application/json"}

@server.catchall()
def catchall(request):
return "Not found", 404

server.run()`

When sending a POST request i get the following error :

Task exception wasn't retrieved
future: coro= <generator object '_handle_request' at 20015230>
Traceback (most recent call last):
File "asyncio/core.py", line 1, in run_until_complete
File "/lib/phew/server.py", line 277, in _handle_request
AttributeError: 'NoneType' object has no attribute 'status'

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.